diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 91591d7b..8dff7465 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -46,7 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import UnknownFlow -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -70,7 +70,6 @@ from .const import ( CONF_QUEUE_DELAY, CONF_SCAN_INTERVAL, DATA_ALEXAMEDIA, - DATA_LISTENER, DEFAULT_EXTENDED_ENTITY_DISCOVERY, DEFAULT_PUBLIC_URL, DEFAULT_QUEUE_DELAY, @@ -744,26 +743,23 @@ async def async_setup_entry(hass, config_entry): "notifications_pending": set(), # doppler serials that need a refresh "notifications_refresh_task": None, # running task or None "notifications_retry_count": 0, # simple backoff counter - "options": { - CONF_INCLUDE_DEVICES: config_entry.data.get(CONF_INCLUDE_DEVICES, ""), - CONF_EXCLUDE_DEVICES: config_entry.data.get(CONF_EXCLUDE_DEVICES, ""), - CONF_QUEUE_DELAY: config_entry.data.get( - CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY - ), - CONF_SCAN_INTERVAL: config_entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - CONF_PUBLIC_URL: config_entry.data.get( - CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL - ), - CONF_EXTENDED_ENTITY_DISCOVERY: config_entry.data.get( - CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY - ), - CONF_DEBUG: config_entry.data.get(CONF_DEBUG, False), - }, - DATA_LISTENER: [config_entry.add_update_listener(update_listener)], }, ) + # Refreshed on every attempt: the account dict survives setup retries and + # a failed setup, so options saved in the meantime would otherwise be lost. + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["options"] = { + CONF_INCLUDE_DEVICES: config_entry.data.get(CONF_INCLUDE_DEVICES, ""), + CONF_EXCLUDE_DEVICES: config_entry.data.get(CONF_EXCLUDE_DEVICES, ""), + CONF_QUEUE_DELAY: config_entry.data.get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), + CONF_SCAN_INTERVAL: config_entry.data.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + CONF_PUBLIC_URL: config_entry.data.get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL), + CONF_EXTENDED_ENTITY_DISCOVERY: config_entry.data.get( + CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY + ), + CONF_DEBUG: config_entry.data.get(CONF_DEBUG, False), + } uuid_dict = await calculate_uuid(hass, email, url) uuid = uuid_dict["uuid"] hass.data[DATA_ALEXAMEDIA]["accounts"][email]["second_account_index"] = uuid_dict[ @@ -797,8 +793,6 @@ async def async_setup_entry(hass, config_entry): if not hass.data[DATA_ALEXAMEDIA]["accounts"][email]["second_account_index"]: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_alexa_media) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, complete_startup) - hass.bus.async_listen("alexa_media_relogin_required", relogin) - hass.bus.async_listen("alexa_media_relogin_success", login_success) try: _t = time.monotonic() cookies = await login.load_cookie() @@ -837,6 +831,16 @@ async def async_setup_entry(hass, config_entry): try: async with async_timeout.timeout(LOGIN_MAX_WAIT_S): await login.login(cookies=cookies) + except (RuntimeError, ValueError) as err: + if "Session is closed" in str(err) or "unrecoverable" in str(err): + _LOGGER.warning( + "Alexa Media Player session is closed. Triggering Re-authentication flow." + ) + # This tells Home Assistant to show a persistent notification requiring reauth + raise ConfigEntryAuthFailed( + "Amazon session closed. Please reconfigure your credentials." + ) from err + raise except asyncio.TimeoutError as err: # An interrupted login can leave partial request state on the # client, which alexapy resumes from on the next attempt. Both @@ -853,9 +857,25 @@ async def async_setup_entry(hass, config_entry): _LOGGER.debug("[BOOT] login completed in %.2fs", time.monotonic() - _t) _t = time.monotonic() if await test_login_status(hass, config_entry, login): + # Registered only on success: HA processes async_on_unload + # callbacks on unload and on the ConfigEntryNotReady retry + # path, but not for a plain `False` return, so registering + # any earlier would leak a pair on every failed login check. + config_entry.async_on_unload( + hass.bus.async_listen("alexa_media_relogin_required", relogin) + ) + config_entry.async_on_unload( + hass.bus.async_listen("alexa_media_relogin_success", login_success) + ) _LOGGER.debug("[BOOT] test_login_status in %.2fs", time.monotonic() - _t) _t = time.monotonic() await setup_alexa(hass, config_entry, login) + # Registered once the entry has loaded, so that Home Assistant + # removes it on unload. Any earlier and each setup retry, or failed + # login check, would leave another copy on the entry. + config_entry.async_on_unload( + config_entry.add_update_listener(update_listener) + ) _LOGGER.debug( "[BOOT] setup_entry total: %.2fs", time.monotonic() - _boot_start ) @@ -3128,8 +3148,6 @@ async def async_unload_entry(hass, entry) -> bool: except Exception: _LOGGER.error("Error unloading: %s", component) await close_connections(hass, email) - for listener in hass.data[DATA_ALEXAMEDIA]["accounts"][email][DATA_LISTENER]: - listener() hass.data[DATA_ALEXAMEDIA]["accounts"].pop(email) # Clean up config flows in progress flows_to_remove = [] diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py index 2b86d355..c1db2754 100644 --- a/custom_components/alexa_media/config_flow.py +++ b/custom_components/alexa_media/config_flow.py @@ -513,6 +513,8 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): ) else: _LOGGER.debug("Using existing login") + if self.config.get("reauth") and self.config.get(CONF_EMAIL): + self.login.email = self.config[CONF_EMAIL] if ( not self.config.get("reauth") and user_input @@ -623,6 +625,9 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): """Handle reauth processing for the config flow.""" self._save_user_input_to_config(user_input) self.config["reauth"] = True + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) reauth_schema = self._update_schema_defaults() _LOGGER.debug( "Creating reauth form with %s", @@ -660,7 +665,16 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): email = login.email _LOGGER.debug("Testing login status: %s", login.status) if login.status and login.status.get("login_successful"): - existing_entry = await self.async_set_unique_id(f"{email} - {login.url}") + existing_entry = getattr(self, "reauth_entry", None) + _LOGGER.debug( + "Reauth existing entry: %s", + existing_entry.entry_id if existing_entry else None, + ) + if existing_entry is None: + existing_entry = await self.async_set_unique_id( + f"{email} - {login.url}" + ) + if self.config.get("reauth"): self.config.pop("reauth") if self.config.get(CONF_SECURITYCODE): @@ -681,10 +695,61 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): ) self.hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) self.hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) + if existing_entry: + old_email = existing_entry.data.get(CONF_EMAIL) + + accounts = self.hass.data[DATA_ALEXAMEDIA]["accounts"] + + target_account = accounts.get(email) + if target_account is not None: + target_entry = target_account.get("config_entry") + if ( + target_entry is None + or target_entry.entry_id != existing_entry.entry_id + ): + _LOGGER.error( + "Cannot reauth %s: runtime account belongs to " + "another config entry", + hide_email(email), + ) + return self.async_abort(reason="already_configured") + + # Runtime accounts only exist for loaded entries, so also check + # every config entry (disabled or not yet loaded) for ownership. + new_unique_id = f"{login.email} - {login.url}" + for other_entry in self.hass.config_entries.async_entries(DOMAIN): + if other_entry.entry_id == existing_entry.entry_id: + continue + if other_entry.unique_id == new_unique_id: + _LOGGER.error( + "Cannot reauth %s: another config entry already " + "owns this account", + hide_email(email), + ) + return self.async_abort(reason="already_configured") + + if old_email and old_email != email and old_email in accounts: + _LOGGER.debug( + "Moving Alexa runtime account from %s to %s", + hide_email(old_email), + hide_email(email), + ) + account_data = accounts.pop(old_email) + account_data["config_entry"] = existing_entry + account_data["login_obj"] = self.login + accounts[email] = account_data + else: + if email in accounts: + accounts[email]["login_obj"] = self.login + self.hass.config_entries.async_update_entry( - existing_entry, data=self.config + existing_entry, + data=self.config, + title=f"{login.email} - {login.url}", + unique_id=f"{login.email} - {login.url}", ) + _LOGGER.debug("Reauth successful for %s", hide_email(email)) self.hass.bus.async_fire( "alexa_media_relogin_success", @@ -696,15 +761,6 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): self.hass, notification_id, ) - if not self.hass.data[DATA_ALEXAMEDIA]["accounts"].get( - self.config[CONF_EMAIL] - ): - self.hass.data[DATA_ALEXAMEDIA]["accounts"][ - self.config[CONF_EMAIL] - ] = {} - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.config[CONF_EMAIL]][ - "login_obj" - ] = self.login self.hass.data[DATA_ALEXAMEDIA]["config_flows"][ f"{email} - {login.url}" ] = None diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index ebd0c170..05cca45e 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -8,11 +8,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alandtse/alexa_media_player/issues", "loggers": ["alexapy", "authcaptureproxy"], - "requirements": [ - "alexapy==1.30.1", - "packaging>=20.3", - "wrapt>=1.14.0", - "dictor>=0.1.12,<0.2" - ], - "version": "5.16.1" + "requirements": ["alexapy==1.31.0", "wrapt>=1.14.0", "dictor>=0.1.12,<0.2"], + "version": "5.16.2" } diff --git a/custom_components/alexa_media/notify.py b/custom_components/alexa_media/notify.py index 77267dff..a334924d 100644 --- a/custom_components/alexa_media/notify.py +++ b/custom_components/alexa_media/notify.py @@ -211,8 +211,9 @@ class AlexaNotificationService(BaseNotificationService): try: targets = json.loads(targets) except json.JSONDecodeError: - _LOGGER.error("Target must be a valid json") - return + targets = [targets] + if targets is None: + targets = [] processed_targets = [] for target in targets: _LOGGER.debug("Processing: %s", target) diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index cade7af0..7b632853 100644 --- a/custom_components/hilo/__init__.py +++ b/custom_components/hilo/__init__.py @@ -215,6 +215,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, implementation ), log_traces=current_options.get(CONF_LOG_TRACES, DEFAULT_LOG_TRACES), + state_yaml=hass.config.path("hilo_state.yaml"), ) except (TimeoutError, client_exceptions.ClientConnectorError): diff --git a/custom_components/hilo/manifest.json b/custom_components/hilo/manifest.json index 78e2cd9a..727102cf 100644 --- a/custom_components/hilo/manifest.json +++ b/custom_components/hilo/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://github.com/dvd-dev/hilo", "iot_class": "cloud_push", "issue_tracker": "https://github.com/dvd-dev/hilo/issues", - "requirements": ["python-hilo==2026.9.1"], - "version": "2026.10.1" + "requirements": ["python-hilo==2026.10.1"], + "version": "2026.10.2" } diff --git a/custom_components/hilo/sensor.py b/custom_components/hilo/sensor.py index 6daf2e25..aaf1fcf1 100644 --- a/custom_components/hilo/sensor.py +++ b/custom_components/hilo/sensor.py @@ -739,7 +739,9 @@ class HiloRewardSensor(HiloEntity, RestoreEntity, SensorEntity): Platform.SENSOR, ) LOG.debug("Setting up RewardSensor entity: %s", self._attr_name) - self._history_state_yaml: str = "hilo_eventhistory_state.yaml" + self._history_state_yaml: str = hilo._hass.config.path( + "hilo_eventhistory_state.yaml" + ) self.scan_interval = timedelta(seconds=REWARD_SCAN_INTERVAL) self._state = 0 self._history = [] diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index d7b23694..23386dc8 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,7 +4,7 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.5, + "voltage": 120.3, "countdown_to_turn_on": 0, "ac_frequency": 60, "power_factor": 0.11, @@ -27,9 +27,9 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "power": 2.1, - "current": 0.1, - "energy": 55.26, + "power": 1.9, + "current": 0.09, + "energy": 55.27, "power_factor": 0.16, "update": { "state": "idle", @@ -47,8 +47,8 @@ "countdown_to_turn_off": 0, "voltage": 119.9, "countdown_to_turn_on": 0, - "energy": 107.28, - "power_factor": 0.2, + "energy": 107.3, + "power_factor": 0.3, "ac_frequency": 60, "update": { "state": "idle", @@ -59,7 +59,7 @@ }, "linkquality": 145, "power": 0.2, - "current": 0.01, + "current": 0.79, "power_on_behavior": "on" }, "0xb40e060fffe031e3": { @@ -74,13 +74,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 118.6, + "voltage": 119.1, "state": "ON", "ac_frequency": 60, - "energy": 218.15, - "power": 102.1, - "current": 0.94, - "power_factor": 0.95, + "energy": 218.19, + "power": 100, + "current": 0.89, + "power_factor": 0.94, "update": { "state": "idle", "installed_version": 268513381, @@ -96,12 +96,12 @@ "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, "voltage": 120.5, - "energy": 102.57, + "energy": 102.58, "state": "ON", - "power": 14, - "current": 0.21, + "power": 14.2, + "current": 0.2, "ac_frequency": 60, - "power_factor": 0.6, + "power_factor": 0.53, "update": { "state": "idle", "installed_version": 268513381, @@ -114,7 +114,7 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 120.3, + "voltage": 120.2, "ac_frequency": 60, "energy": 15.03, "current": 0.01, @@ -136,14 +136,14 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.6, + "voltage": 121, "energy": 36.91, "countdown_to_turn_on": 0, "state": "ON", "current": 0.02, "ac_frequency": 60, - "power": 0.4, - "power_factor": 0.18, + "power": 0.3, + "power_factor": 0.19, "update": { "state": "idle", "installed_version": 268513381,