diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f9dc7..92247ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Crash on setup when no devices are usable or initial fetch fails**: replaced all uses of the removed `hass.components.persistent_notification` accessor with the current `homeassistant.components.persistent_notification.async_create(hass, ...)` API. Previously, any code path that tried to show a persistent notification (no devices found, initial connection failure, invalid/read-only property, failed switch/select control) raised `AttributeError: 'HomeAssistant' object has no attribute 'components'` on modern Home Assistant, aborting integration setup entirely instead of surfacing the intended message (#8) + ## [0.5.0] - 2026-04-10 ### Changed diff --git a/custom_components/pecron/__init__.py b/custom_components/pecron/__init__.py index 039f03d..5ae1b2c 100644 --- a/custom_components/pecron/__init__.py +++ b/custom_components/pecron/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta from typing import Final +from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, ServiceCall @@ -42,9 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL), ) - coordinator = PecronDataUpdateCoordinator( - hass, email, password, region, refresh_interval - ) + coordinator = PecronDataUpdateCoordinator(hass, email, password, region, refresh_interval) # Attempt initial refresh with retry logic max_retries = 3 @@ -71,7 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: max_retries, ) # Show notification about the failure - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, f"Failed to connect to Pecron API after {max_retries} attempts. " "Please check your internet connection and credentials, then reload the integration.", title="Pecron: Connection Failed", @@ -82,7 +82,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Show persistent notification if no devices found if coordinator.data is not None and not coordinator.data: - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, "No Pecron devices found on your account. " "Please check that devices are registered in the Pecron mobile app and try reloading the integration.", title="Pecron: No Devices Found", @@ -156,7 +157,8 @@ async def async_handle_set_property(call: ServiceCall) -> None: device.device_name, list(tsl_property_codes.keys()), ) - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, f"Property '{property_code}' is not supported by {device.device_name}. " f"Available properties: {', '.join(sorted(tsl_property_codes.keys()))}", title="Pecron: Invalid Property", @@ -172,7 +174,8 @@ async def async_handle_set_property(call: ServiceCall) -> None: property_code, device.device_name, ) - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, f"Property '{property_code}' is read-only and cannot be modified.", title="Pecron: Read-Only Property", notification_id=f"{DOMAIN}_readonly_property_{device_key}", @@ -207,7 +210,8 @@ async def async_handle_set_property(call: ServiceCall) -> None: device.device_name, result.message or "Unknown error", ) - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, f"Failed to set property '{property_code}' on {device.device_name}: " f"{result.message or 'Unknown error'}", title="Pecron: Property Set Failed", @@ -222,7 +226,8 @@ async def async_handle_set_property(call: ServiceCall) -> None: err, exc_info=True, ) - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, f"Error setting property '{property_code}' on {device.device_name}: {err}", title="Pecron: Service Error", notification_id=f"{DOMAIN}_set_property_error_{device_key}", @@ -300,8 +305,13 @@ async def _async_update_data(self) -> dict: last_error = err error_str = str(err).lower() # Differentiate error types for better diagnostics - if ("authentication" in error_str or "401" in error_str or "unauthorized" in error_str or - "5032" in error_str or "token" in error_str): + if ( + "authentication" in error_str + or "401" in error_str + or "unauthorized" in error_str + or "5032" in error_str + or "token" in error_str + ): if attempt < max_retries - 1: # Token likely expired - reset API to force re-login on next attempt _LOGGER.warning( @@ -325,7 +335,9 @@ async def _async_update_data(self) -> dict: _LOGGER.warning("Connection error while communicating with Pecron API: %s", err) raise UpdateFailed(f"Connection error: {err}") from err else: - _LOGGER.error("Unexpected error communicating with Pecron API: %s", err, exc_info=True) + _LOGGER.error( + "Unexpected error communicating with Pecron API: %s", err, exc_info=True + ) raise UpdateFailed(f"Error communicating with Pecron API: {err}") from err # If we exhausted retries without raising, raise the last error @@ -349,7 +361,10 @@ def _fetch_data(self) -> dict: self.devices = self.api.get_devices() if is_refresh: - _LOGGER.info("Token refreshed successfully. Found %d Pecron device(s) on account", len(self.devices)) + _LOGGER.info( + "Token refreshed successfully. Found %d Pecron device(s) on account", + len(self.devices), + ) else: _LOGGER.info("Found %d Pecron device(s) on account", len(self.devices)) @@ -357,7 +372,7 @@ def _fetch_data(self) -> dict: _LOGGER.warning( "No Pecron devices found on account %s. " "Please check that devices are registered in the Pecron app.", - self.email + self.email, ) else: for device in self.devices: @@ -434,8 +449,13 @@ def _fetch_data(self) -> dict: except Exception as err: error_str = str(err).lower() # Check if this is an authentication error that needs token refresh - if ("authentication" in error_str or "401" in error_str or "unauthorized" in error_str or - "5032" in error_str or "token" in error_str): + if ( + "authentication" in error_str + or "401" in error_str + or "unauthorized" in error_str + or "5032" in error_str + or "token" in error_str + ): _LOGGER.warning( "Authentication error fetching properties for %s: %s. Token may have expired.", device.device_name, @@ -475,11 +495,7 @@ def get_new_devices(self, existing_keys: set[str]) -> dict: """Get devices that are new since the given set of keys.""" if not self.data: return {} - return { - key: value - for key, value in self.data.items() - if key not in existing_keys - } + return {key: value for key, value in self.data.items() if key not in existing_keys} async def async_shutdown(self) -> None: """Shutdown the coordinator and close API connection.""" diff --git a/custom_components/pecron/select.py b/custom_components/pecron/select.py index 7fe2e9a..1652238 100644 --- a/custom_components/pecron/select.py +++ b/custom_components/pecron/select.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from typing import Any +from homeassistant.components import persistent_notification from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -248,7 +249,8 @@ async def async_select_option(self, option: str) -> None: api = self.coordinator.api if api is None: _LOGGER.error("API not available for %s", self._attr_name) - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Failed to control {self._attr_name}: API not initialized", title="Pecron: Control Failed", notification_id=f"{DOMAIN}_control_failed_{self._attr_unique_id}", @@ -275,9 +277,7 @@ async def async_select_option(self, option: str) -> None: self.async_write_ha_state() try: - result = await self.hass.async_add_executor_job( - method, self._device, api_value - ) + result = await self.hass.async_add_executor_job(method, self._device, api_value) if not result.success: _LOGGER.error( @@ -288,7 +288,8 @@ async def async_select_option(self, option: str) -> None: ) self._attr_current_option = old_option self.async_write_ha_state() - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Failed to set {self._attr_name} to {option}: " f"{result.error_message or 'Unknown error'}", title="Pecron: Control Failed", @@ -315,7 +316,8 @@ async def delayed_refresh(delay: int) -> None: ) self._attr_current_option = old_option self.async_write_ha_state() - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Error controlling {self._attr_name}: {err}", title="Pecron: Control Error", notification_id=f"{DOMAIN}_control_error_{self._attr_unique_id}", diff --git a/custom_components/pecron/switch.py b/custom_components/pecron/switch.py index 319696c..6fcdd15 100644 --- a/custom_components/pecron/switch.py +++ b/custom_components/pecron/switch.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any +from homeassistant.components import persistent_notification from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -78,7 +79,10 @@ def create_switches_for_device(device_key: str, device_data: dict) -> list: for switch_desc in PECRON_SWITCHES: # Check both the property name and the _hm variant # (API maps ac_switch_hm -> ac_switch in properties) - if switch_desc.key in tsl_property_codes or f"{switch_desc.key}_hm" in tsl_property_codes: + if ( + switch_desc.key in tsl_property_codes + or f"{switch_desc.key}_hm" in tsl_property_codes + ): switches.append( PecronSwitch( coordinator, @@ -219,7 +223,8 @@ async def _async_set_state(self, enabled: bool) -> None: api = self.coordinator.api if api is None: _LOGGER.error("API not available for %s", self._attr_name) - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Failed to control {self._attr_name}: API not initialized", title="Pecron: Control Failed", notification_id=f"{DOMAIN}_control_failed_{self._attr_unique_id}", @@ -245,9 +250,7 @@ async def _async_set_state(self, enabled: bool) -> None: try: # Call the API method in executor - result = await self.hass.async_add_executor_job( - method, self._device, enabled - ) + result = await self.hass.async_add_executor_job(method, self._device, enabled) if not result.success: _LOGGER.error( @@ -259,7 +262,8 @@ async def _async_set_state(self, enabled: bool) -> None: # Revert optimistic update on failure self._attr_is_on = old_state self.async_write_ha_state() - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Failed to {'turn on' if enabled else 'turn off'} {self._attr_name}: " f"{result.message or 'Unknown error'}", title="Pecron: Control Failed", @@ -300,7 +304,8 @@ async def delayed_refresh(delay: int) -> None: # Revert optimistic update on error self._attr_is_on = old_state self.async_write_ha_state() - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, f"Error controlling {self._attr_name}: {err}", title="Pecron: Control Error", notification_id=f"{DOMAIN}_control_error_{self._attr_unique_id}",