diff --git a/.ha_run.lock b/.ha_run.lock index 7f5a6aa8..ed660fe4 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.9.2", "start_ts": 1789331676.107018} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.9.2", "start_ts": 1789390417.876061} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index 2b5becff..5dc42f9a 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -206,7 +206,7 @@ }, { "id": "d3c3e144a91247ce8ac524814737d7f8", - "url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164662", + "url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164663", "type": "module" }, { @@ -315,13 +315,13 @@ "type": "module" }, { - "id": "01719b0125f941f38ea1e62eead86a94", - "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", + "id": "1a4939ce1d2b47f89502176d3c5ac6e7", + "url": "/taskmate/taskmate-sounds.js?v=5.6.0", "type": "module" }, { - "id": "1a4939ce1d2b47f89502176d3c5ac6e7", - "url": "/taskmate/taskmate-sounds.js?v=5.6.0", + "id": "e74d5108a99f4ffbbb445c9c41a17282", + "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" } ] diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 0e12998d..91591d7b 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -8,6 +8,7 @@ https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers """ import asyncio +import contextlib from datetime import datetime, timedelta from json import JSONDecodeError, loads import logging @@ -92,6 +93,7 @@ from .const import ( LAST_CALLED_SUCCESS_PACE_S, LAST_PING_MAX_AGE_SECONDS, LAST_PUSH_INACTIVITY_SECONDS, + LOGIN_MAX_WAIT_S, MIN_TIME_BETWEEN_FORCED_SCANS, MIN_TIME_BETWEEN_SCANS, NOTIFICATION_COOLDOWN, @@ -691,6 +693,14 @@ async def async_setup_entry(hass, config_entry): hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) hass.data[DATA_ALEXAMEDIA].setdefault("notify_service", None) + # The collector is removed when the last account unloads, so recreating it + # here re-arms boot tracking at the domain lifecycle boundary: a reload + # measures its own setup instead of the async_setup of this Home Assistant + # run. Tracking is deliberately not re-armed for an additional account, + # which would discard the stages already recorded for the loaded ones. + if not isinstance(hass.data[DATA_ALEXAMEDIA].get("metrics"), AlexaMetrics): + hass.data[DATA_ALEXAMEDIA]["metrics"] = AlexaMetrics(hass) + hass.data[DATA_ALEXAMEDIA]["metrics"].start_boot_tracking() account = config_entry.data email = account.get(CONF_EMAIL) password = account.get(CONF_PASSWORD) @@ -824,7 +834,22 @@ async def async_setup_entry(hass, config_entry): except (JSONDecodeError, ValueError, aiohttp.ClientError) as ex: _LOGGER.debug("[BOOT] Bootstrap cookie auth check failed: %s", ex) if not cookie_login_ok: - await login.login(cookies=cookies) + try: + async with async_timeout.timeout(LOGIN_MAX_WAIT_S): + await login.login(cookies=cookies) + 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 + # hass.data and runtime_data would hand that same client to the + # retry, so drop it and let the retry build a fresh one; the + # saved cookie is left in place. + with contextlib.suppress(Exception): + await login.close() + hass.data[DATA_ALEXAMEDIA]["accounts"][email].pop("login_obj", None) + config_entry.runtime_data = None + raise ConfigEntryNotReady( + f"Login did not complete within {LOGIN_MAX_WAIT_S:.0f}s" + ) from err _LOGGER.debug("[BOOT] login completed in %.2fs", time.monotonic() - _t) _t = time.monotonic() if await test_login_status(hass, config_entry, login): @@ -973,8 +998,12 @@ async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): for binary_sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "entities" ].get("binary_sensor", []): - if binary_sensor.enabled: - entities_to_monitor.add(binary_sensor.alexa_entity_id) + # AmazonKidsSensor is stored alongside the coordinator-backed + # AlexaContact sensors but is not derived from an Alexa entity: it + # polls the Echo on its own interval and has no alexa_entity_id. + alexa_entity_id = getattr(binary_sensor, "alexa_entity_id", None) + if alexa_entity_id and binary_sensor.enabled: + entities_to_monitor.add(alexa_entity_id) for guard in ( hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"] @@ -3123,6 +3152,11 @@ async def async_unload_entry(hass, entry) -> bool: if alexa_services: await alexa_services.unregister() hass.data[DATA_ALEXAMEDIA].pop("services") + # Per-run collectors recreated by the next setup. Leaving them behind + # keeps DATA_ALEXAMEDIA truthy, so the data structure below is never + # removed and stale boot metrics survive into the next setup. + hass.data[DATA_ALEXAMEDIA].pop("metrics", None) + hass.data[DATA_ALEXAMEDIA].pop("notify_service", None) if hass.data[DATA_ALEXAMEDIA].get("config_flows") == {}: _LOGGER.debug("Removing config_flows data") async_dismiss_persistent_notification( diff --git a/custom_components/alexa_media/binary_sensor.py b/custom_components/alexa_media/binary_sensor.py index 297c074e..46037935 100644 --- a/custom_components/alexa_media/binary_sensor.py +++ b/custom_components/alexa_media/binary_sensor.py @@ -202,7 +202,6 @@ class AmazonKidsSensor(BinarySensorEntity): """Attach to the Echo device.""" return { "identifiers": {(DATA_ALEXAMEDIA, self._client.unique_id)}, - "via_device": (DATA_ALEXAMEDIA, self._client.unique_id), } async def async_added_to_hass(self): diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index d9e8bcb5..8f70f532 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -99,6 +99,9 @@ LAST_CALLED_429_BACKOFF_MAX_S = 15 * 60.0 LAST_CALLED_CONN_BACKOFF_S = 10.0 LAST_CALLED_LOGIN_BACKOFF_S = 30.0 +# Deadlock guard for AlexaLogin.login() during config entry setup. +LOGIN_MAX_WAIT_S = 600.0 + # Tuning constants for the per-account last-called probe worker LAST_CALLED_DEBOUNCE_S = 3.5 # coalesce bursty pushes, but stay snappy LAST_CALLED_RETRY_DELAY_S = 4.0 # wider retry cadence for delayed routine history diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index 38e2ee4f..ebd0c170 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -9,10 +9,10 @@ "issue_tracker": "https://github.com/alandtse/alexa_media_player/issues", "loggers": ["alexapy", "authcaptureproxy"], "requirements": [ - "alexapy==1.30.0", + "alexapy==1.30.1", "packaging>=20.3", "wrapt>=1.14.0", "dictor>=0.1.12,<0.2" ], - "version": "5.16.0" + "version": "5.16.1" } diff --git a/custom_components/alexa_media/switch.py b/custom_components/alexa_media/switch.py index fcc12791..ed9f98ce 100644 --- a/custom_components/alexa_media/switch.py +++ b/custom_components/alexa_media/switch.py @@ -315,7 +315,6 @@ class AlexaMediaSwitch(SwitchDevice, AlexaMedia): """Return device_info for device registry.""" return { "identifiers": {(ALEXA_DOMAIN, self._client.unique_id)}, - "via_device": (ALEXA_DOMAIN, self._client.unique_id), } @property diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 1c45391d..ee53eea8 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -1426,6 +1426,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf await shopping_sync.async_setup() hass.data[DOMAIN][SHOPPING_SYNC_KEY] = shopping_sync + # D#183: per-task mirror into external to-do lists — same lifecycle + # as the shopping sync (global entry owns it). + from .helpers.todo_mirror import TODO_MIRROR_KEY, TodoMirror + + if TODO_MIRROR_KEY not in hass.data[DOMAIN]: + todo_mirror = TodoMirror(hass) + await todo_mirror.async_setup() + hass.data[DOMAIN][TODO_MIRROR_KEY] = todo_mirror + # One-time migration: auto-enable advanced feature flags for existing users options = dict(entry.options or entry.data) if CONF_ADVANCED_ADAPTIVE not in options: @@ -2000,6 +2009,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon shopping_sync = hass.data.get(DOMAIN, {}).pop(SHOPPING_SYNC_KEY, None) if shopping_sync is not None: shopping_sync.async_teardown() + from .helpers.todo_mirror import TODO_MIRROR_KEY + + todo_mirror = hass.data.get(DOMAIN, {}).pop(TODO_MIRROR_KEY, None) + if todo_mirror is not None: + todo_mirror.async_teardown() text_index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY) if text_index is not None: text_index.cancel() diff --git a/custom_components/maintenance_supporter/config_flow_options_global.py b/custom_components/maintenance_supporter/config_flow_options_global.py index d3435243..54b40425 100644 --- a/custom_components/maintenance_supporter/config_flow_options_global.py +++ b/custom_components/maintenance_supporter/config_flow_options_global.py @@ -30,6 +30,8 @@ from .const import ( CONF_ADVANCED_PREDICTIONS, CONF_ADVANCED_SCHEDULE_TIME, CONF_ADVANCED_SEASONAL, + CONF_BATTERY_LOW_PERCENT, + CONF_BATTERY_RECOVERED_PERCENT, CONF_BUDGET_ALERT_THRESHOLD, CONF_BUDGET_ALERTS_ENABLED, CONF_BUDGET_CURRENCY, @@ -55,6 +57,7 @@ from .const import ( CONF_OPERATOR_WRITE_ENABLED, CONF_PANEL_ENABLED, CONF_PANEL_TITLE, + CONF_PART_SEARCH_URL_TEMPLATE, CONF_QUIET_HOURS_ENABLED, CONF_QUIET_HOURS_END, CONF_QUIET_HOURS_START, @@ -620,6 +623,15 @@ class GlobalOptionsFlow(OptionsFlow): if isinstance(raw_title, str): user_input[CONF_PANEL_TITLE] = raw_title.strip()[:MAX_PANEL_TITLE_LENGTH] + # D#182: shopping-search template — blank = automatic (country, + # then UI language); otherwise an http(s) URL with {q}. + from .helpers.parts import valid_search_template + + raw_tpl = (user_input.get(CONF_PART_SEARCH_URL_TEMPLATE) or "").strip() + if raw_tpl and not valid_search_template(raw_tpl): + errors[CONF_PART_SEARCH_URL_TEMPLATE] = "invalid_search_template" + user_input[CONF_PART_SEARCH_URL_TEMPLATE] = raw_tpl + if not errors: return self._save_and_return(user_input) @@ -667,6 +679,30 @@ class GlobalOptionsFlow(OptionsFlow): mode=selector.NumberSelectorMode.BOX, ) ), + # #146 / #180: the battery fleet's low floor and the level a + # low battery must rise above to count as replaced. + vol.Optional( + CONF_BATTERY_LOW_PERCENT, + default=self._opt(CONF_BATTERY_LOW_PERCENT), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=int_range(CONF_BATTERY_LOW_PERCENT)[0], + max=int_range(CONF_BATTERY_LOW_PERCENT)[1], + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional( + CONF_BATTERY_RECOVERED_PERCENT, + default=self._opt(CONF_BATTERY_RECOVERED_PERCENT), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=int_range(CONF_BATTERY_RECOVERED_PERCENT)[0], + max=int_range(CONF_BATTERY_RECOVERED_PERCENT)[1], + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ), vol.Optional( CONF_NOTIFICATIONS_ENABLED, default=self._opt(CONF_NOTIFICATIONS_ENABLED), @@ -698,6 +734,12 @@ class GlobalOptionsFlow(OptionsFlow): CONF_PANEL_TITLE, description={"suggested_value": current.get(CONF_PANEL_TITLE, "")}, ): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)), + # D#182: shopping-search template with {q}; blank = automatic + # (Amazon store of the HA country, then of the UI language). + vol.Optional( + CONF_PART_SEARCH_URL_TEMPLATE, + description={"suggested_value": current.get(CONF_PART_SEARCH_URL_TEMPLATE, "")}, + ): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.URL)), } ), errors=errors, diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index 876e6f39..9fd1e225 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -307,6 +307,9 @@ COMPLETION_SOURCES = ( "voice", "notification_action", "shopping_list", + # D#183: checked off in a mirrored external to-do list — a person did + # it, so it is NOT automatic (same class as "todo"). + "todo_mirror", "service", "auto_recovery", ) @@ -321,8 +324,15 @@ COMPLETION_PROVENANCE_NOTES: dict[str, str] = { "notification_action": "Completed from the notification", "nfc": "Completed via NFC tag", "shopping_list": "Completed from the shopping list", + "todo_mirror": "Completed from a mirrored to-do list", } +# D#183: per-task mirror into external ``todo.*`` lists (family members work +# from their to-do app; checking the row off completes the task here). The +# list of entity ids is capped and shape-validated at every write path. +MAX_MIRROR_TODO_LISTS = 5 +MIRROR_TODO_ENTITY_PATTERN = r"^todo\.[a-z0-9_]+$" + # --- Config Keys: Notification Actions --- CONF_ACTION_COMPLETE_ENABLED = "action_complete_enabled" CONF_ACTION_SKIP_ENABLED = "action_skip_enabled" @@ -855,3 +865,13 @@ MAX_VACATION_EXEMPT_TASKS = 2000 # --- Trigger Entity Availability --- STARTUP_GRACE_PERIOD_SECONDS = 300 # 5 minutes MISSING_ENTITY_THRESHOLD_REFRESHES = 6 # ~30 min at 5-min intervals + +# --- Battery fleet hysteresis (#180) --- +# A battery that went low stays counted low until its level rises ABOVE this +# percent (or a replacement is recorded) — a level oscillating around the low +# floor must not flip the fleet task's auto-complete on every dip. +CONF_BATTERY_RECOVERED_PERCENT = "battery_recovered_percent" +DEFAULT_BATTERY_RECOVERED_PERCENT = 50 +BATTERY_RECOVERED_PERCENT_RANGE: tuple[int, int] = (20, 100) +# D#182: the shopping-search template is user-settable (max URL length). +MAX_PART_SEARCH_URL_TEMPLATE_LENGTH = 500 diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index 0410bca2..2d8ec2fc 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -481,8 +481,23 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._todo_entity is not None and self._todo_entity.hass is not None: self._todo_entity.refresh() + # D#183: mirror the due tasks into their external to-do lists (a + # no-op unless a task names one). + await self._async_sync_todo_mirror(result[CONF_TASKS]) + return result + async def _async_sync_todo_mirror(self, task_results: dict[str, Any]) -> None: + """Hand this refresh's results to the to-do mirror (helpers/todo_mirror).""" + from .helpers.todo_mirror import TODO_MIRROR_KEY + + mirror = self.hass.data.get(DOMAIN, {}).get(TODO_MIRROR_KEY) + if mirror is None: + return + await mirror.async_sync_entry( + self.entry.entry_id, task_results, store=self._store, object_name=self.maintenance_object.name + ) + async def _evaluate_trigger_fallback(self, task: MaintenanceTask, task_id: str) -> None: """Evaluate trigger state as fallback (main evaluation is event-driven). diff --git a/custom_components/maintenance_supporter/export.py b/custom_components/maintenance_supporter/export.py index 34031d12..2b3af999 100644 --- a/custom_components/maintenance_supporter/export.py +++ b/custom_components/maintenance_supporter/export.py @@ -115,6 +115,8 @@ def _build_export_object( # for documentation_url/notes; import mirrors these keys). "priority": tdata.get("priority", "normal"), "labels": tdata.get("labels") or [], + # D#183: mirror targets (todo.* ids) — config, so a backup keeps them. + "mirror_todo_entities": tdata.get("mirror_todo_entities") or [], "earliest_completion_days": tdata.get("earliest_completion_days"), # #170: reference number — a restore keeps "8.3" on the booklets # already printed; a colliding import is renumbered on setup. diff --git a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts index 86ed4f0e..4554ebdf 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts @@ -410,15 +410,18 @@ export class MaintenanceBatteryFleetSection extends LitElement { /** One-click fix for a detected-but-unrecorded swap: record the DETECTED * jump time in Battery Notes, so the forecast re-anchors on the real - * replacement instead of the dead battery's date. */ + * replacement instead of the dead battery's date. Goes through the + * backend (#181) so the swap also consumes the type-part cells from + * stock, exactly like the Replaced action. */ private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise { if (this._marking) return; this._marking = true; this._error = ""; try { - await this.hass.callService("battery_notes", "set_battery_replaced", { - device_id: jump.device_id, - datetime_replaced: new Date(jump.at * 1000).toISOString(), + await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/battery_fleet/record_replacement", + entity_id: entityId, + replaced_at: new Date(jump.at * 1000).toISOString(), }); this._recorded = [...this._recorded, entityId]; await this._load(); diff --git a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts index afe073a5..e2adb7f7 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts @@ -54,6 +54,11 @@ interface SettingsResponse { /** #146: household "low" floors (percent) for discovery + battery fleet. */ default_consumable_threshold?: number; battery_low_percent?: number; + /** #180: a low battery counts as replaced only once its level is above this. */ + battery_recovered_percent?: number; + /** D#182: shopping-search URL with {q} ("" = automatic) + the automatic value in effect. */ + part_search_url_template?: string; + part_search_url_default?: string; /** D#162 follow-up: per-type lifetime overrides + the computed catalog. */ battery_lifetime_months?: Record; battery_lifetimes?: { @@ -860,9 +865,23 @@ export class MaintenanceSettingsView extends LitElement { ${t("settings_battery_low_percent", L)} ${this._intSetting("battery_low_percent", g.battery_low_percent ?? 20)} + +
${t("settings_battery_recovered_percent_hint", L)}
${this._renderBatteryNotesHint(L)} ${this._renderBatteryLifetimes(L)}
${t("settings_thresholds_hint", L)}
+ +
${t("settings_part_search_url_hint", L)}