From bd8280defea77bc956af936f6fa0ce13e1d2fce3 Mon Sep 17 00:00:00 2001 From: Home Assistant Version Control Date: Mon, 14 Sep 2026 12:56:59 +0000 Subject: [PATCH] 186 files --- .ha_run.lock | 2 +- .storage/lovelace_resources | 10 +- custom_components/alexa_media/__init__.py | 40 +- .../alexa_media/binary_sensor.py | 1 - custom_components/alexa_media/const.py | 3 + custom_components/alexa_media/manifest.json | 4 +- custom_components/alexa_media/switch.py | 1 - .../maintenance_supporter/__init__.py | 14 + .../config_flow_options_global.py | 42 + .../maintenance_supporter/const.py | 20 + .../maintenance_supporter/coordinator.py | 15 + .../maintenance_supporter/export.py | 2 + .../components/battery-fleet-section.ts | 11 +- .../frontend-src/components/settings-view.ts | 19 + .../frontend-src/components/task-dialog.ts | 34 + .../frontend-src/helpers/storage-keys.ts | 3 + .../frontend-src/locales/cs.json | 10 + .../frontend-src/locales/da.json | 10 + .../frontend-src/locales/de.json | 10 + .../frontend-src/locales/en.json | 10 + .../frontend-src/locales/es.json | 10 + .../frontend-src/locales/fi.json | 10 + .../frontend-src/locales/fr.json | 10 + .../frontend-src/locales/hi.json | 10 + .../frontend-src/locales/hu.json | 10 + .../frontend-src/locales/it.json | 10 + .../frontend-src/locales/ja.json | 10 + .../frontend-src/locales/ko.json | 10 + .../frontend-src/locales/nb.json | 10 + .../frontend-src/locales/nl.json | 10 + .../frontend-src/locales/pl.json | 10 + .../frontend-src/locales/pt-br.json | 10 + .../frontend-src/locales/pt.json | 10 + .../frontend-src/locales/ru.json | 10 + .../frontend-src/locales/sv.json | 10 + .../frontend-src/locales/tr.json | 10 + .../frontend-src/locales/uk.json | 10 + .../frontend-src/locales/zh.json | 10 + .../frontend-src/maintenance-panel.ts | 99 +- .../frontend-src/panel-styles.ts | 26 + .../frontend-src/renderers/task-detail.ts | 4 + .../frontend-src/types.ts | 2 + .../frontend-src/ws-errors.ts | 2 + .../frontend/locales/cs.json | 10 + .../frontend/locales/da.json | 10 + .../frontend/locales/de.json | 10 + .../frontend/locales/en.json | 10 + .../frontend/locales/es.json | 10 + .../frontend/locales/fi.json | 10 + .../frontend/locales/fr.json | 10 + .../frontend/locales/hi.json | 10 + .../frontend/locales/hu.json | 10 + .../frontend/locales/it.json | 10 + .../frontend/locales/ja.json | 10 + .../frontend/locales/ko.json | 10 + .../frontend/locales/nb.json | 10 + .../frontend/locales/nl.json | 10 + .../frontend/locales/pl.json | 10 + .../frontend/locales/pt-br.json | 10 + .../frontend/locales/pt.json | 10 + .../frontend/locales/ru.json | 10 + .../frontend/locales/sv.json | 10 + .../frontend/locales/tr.json | 10 + .../frontend/locales/uk.json | 10 + .../frontend/locales/zh.json | 10 + .../frontend/maintenance-calendar-card.js | 33 +- .../frontend/maintenance-card.js | 39 +- .../frontend/maintenance-panel.js | 1387 +++---- .../frontend/maintenance-strategy-shim.js | 4 +- .../adopt-problem-sensors-dialog-MUGSDLVO.js | 250 ++ .../frontend/panel-chunks/chunk-6FKJJO4T.js | 2 + .../frontend/panel-chunks/chunk-A2LPO2HZ.js | 2 + .../frontend/panel-chunks/chunk-AN3A6RAI.js | 79 + .../frontend/panel-chunks/chunk-AZKZNFFK.js | 1431 ++++++++ .../frontend/panel-chunks/chunk-BFRW5MGC.js | 1289 +++++++ .../frontend/panel-chunks/chunk-FS7VLXOK.js | 213 ++ .../frontend/panel-chunks/chunk-GRODOZHK.js | 153 + .../frontend/panel-chunks/chunk-KRAXVQN3.js | 2 + .../frontend/panel-chunks/chunk-NOXOKE42.js | 2 + .../frontend/panel-chunks/chunk-OHSAYUYA.js | 2 + .../frontend/panel-chunks/chunk-OMI4RPEK.js | 2 + .../frontend/panel-chunks/chunk-R7OIT4C5.js | 448 +++ .../frontend/panel-chunks/chunk-WZP72YIY.js | 88 + .../frontend/panel-chunks/chunk-X5NUHJD3.js | 937 +++++ .../frontend/panel-chunks/chunk-XXGPRYJO.js | 2 + .../panel-chunks/complete-dialog-UPLYCC44.js | 2 + .../panel-chunks/dialog-mount-LCWF3KPK.js | 2 + .../panel-chunks/object-dialog-JFCVO3XR.js | 2 + .../panel-chunks/qr-dialog-JXN7TAPX.js | 2 + .../panel-chunks/settings-view-XJQ7WKV6.js | 1357 +++++++ .../suggested-setups-dialog-YAP3OISD.js | 146 + .../panel-chunks/task-dialog-PZMOKNLO.js | 2 + .../chunks/budget-section-card-ZJW7YTHU.js | 125 + .../strategy/chunks/chunk-A7GYNFDF.js | 1289 +++++++ .../strategy/chunks/chunk-APM7GQQR.js | 2 + .../strategy/chunks/chunk-CWHLCD4F.js | 2 + .../strategy/chunks/chunk-DFNEXAVU.js | 88 + .../strategy/chunks/chunk-TY5VMGUH.js | 60 + .../strategy/chunks/dialog-mount-EX65KHSI.js | 3232 +++++++++++++++++ .../chunks/groups-section-card-H7274XMG.js | 137 + .../chunks/vacation-section-card-JZ3FWKSE.js | 134 + .../maintenance-dashboard-strategy.js | 6 +- .../helpers/battery_fleet.py | 175 +- .../helpers/battery_fleet_setup.py | 107 +- .../helpers/notification_manager.py | 22 + .../maintenance_supporter/helpers/parts.py | 59 +- .../maintenance_supporter/helpers/sanitize.py | 31 + .../helpers/settings_registry.py | 10 + .../maintenance_supporter/manifest.json | 2 +- .../models/maintenance_task.py | 7 + .../maintenance_supporter/parts_runtime.py | 1 + .../maintenance_supporter/strings.json | 13 +- .../translations/cs.json | 13 +- .../translations/da.json | 13 +- .../translations/de.json | 13 +- .../translations/en.json | 15 +- .../translations/es.json | 13 +- .../translations/fi.json | 13 +- .../translations/fr.json | 13 +- .../translations/hi.json | 13 +- .../translations/hu.json | 13 +- .../translations/it.json | 13 +- .../translations/ja.json | 13 +- .../translations/ko.json | 13 +- .../translations/nb.json | 13 +- .../translations/nl.json | 13 +- .../translations/pl.json | 13 +- .../translations/pt-BR.json | 13 +- .../translations/pt.json | 13 +- .../translations/ru.json | 13 +- .../translations/sv.json | 13 +- .../translations/tr.json | 13 +- .../translations/uk.json | 13 +- .../translations/zh-Hans.json | 13 +- .../websocket/__init__.py | 6 +- .../websocket/battery_fleet.py | 34 + .../websocket/dashboard.py | 43 +- .../maintenance_supporter/websocket/io.py | 13 + .../websocket/tasks_crud.py | 44 +- .../websocket/tasks_validation.py | 26 + custom_components/powercalc/__init__.py | 10 +- custom_components/powercalc/const.py | 1 + .../powercalc/flow_helper/profile_preview.py | 1 + custom_components/powercalc/manifest.json | 2 +- .../powercalc/power_profile/loader/remote.py | 96 +- .../powercalc/power_profile/power_profile.py | 9 +- .../powercalc/sensors/group/custom.py | 20 +- .../powercalc/sensors/group/standby.py | 21 +- .../powercalc/strategy/factory.py | 56 +- .../powercalc/strategy/linear.py | 75 +- custom_components/powercalc/strategy/lut.py | 12 +- .../powercalc/strategy/playbook.py | 5 +- .../powercalc/translations/nl.json | 240 +- .../versatile_thermostat/__init__.py | 11 + .../versatile_thermostat/auto_tpi_manager.py | 17 +- .../versatile_thermostat/base_entity.py | 13 +- .../versatile_thermostat/base_thermostat.py | 73 +- .../versatile_thermostat/binary_sensor.py | 5 +- .../versatile_thermostat/config_schema.py | 14 + .../versatile_thermostat/const.py | 69 +- .../versatile_thermostat/cycle_scheduler.py | 36 +- .../feature_central_boiler_manager.py | 11 +- .../feature_central_power_manager.py | 62 +- .../feature_power_manager.py | 66 +- .../feature_repair_incorrect_state_manager.py | 22 +- .../versatile_thermostat/manifest.json | 2 +- .../versatile_thermostat/number.py | 15 +- .../versatile_thermostat/prop_handler_tpi.py | 1 + .../versatile_thermostat/sensor.py | 50 +- .../versatile_thermostat/strings.json | 23 +- .../versatile_thermostat/thermostat_valve.py | 40 +- .../versatile_thermostat/translations/cs.json | 23 +- .../versatile_thermostat/translations/de.json | 25 +- .../versatile_thermostat/translations/el.json | 31 +- .../versatile_thermostat/translations/en.json | 21 +- .../versatile_thermostat/translations/fr.json | 21 +- .../versatile_thermostat/translations/it.json | 23 +- .../versatile_thermostat/translations/pl.json | 21 +- .../versatile_thermostat/translations/ru.json | 21 +- .../versatile_thermostat/translations/sk.json | 21 +- .../translations/zh-Hans.json | 27 +- .../underlying_state_manager.py | 2 +- .../versatile_thermostat/underlyings.py | 15 +- www/community/lovelace-wallpanel/wallpanel.js | 2 +- .../lovelace-wallpanel/wallpanel.js.gz | Bin 68439 -> 69244 bytes zigbee2mqtt/state.json | 32 +- 186 files changed, 14904 insertions(+), 1087 deletions(-) create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/adopt-problem-sensors-dialog-MUGSDLVO.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-6FKJJO4T.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-A2LPO2HZ.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-AN3A6RAI.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-AZKZNFFK.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-BFRW5MGC.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-FS7VLXOK.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-GRODOZHK.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-KRAXVQN3.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-NOXOKE42.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-OHSAYUYA.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-OMI4RPEK.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-R7OIT4C5.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-WZP72YIY.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-X5NUHJD3.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-XXGPRYJO.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/complete-dialog-UPLYCC44.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/dialog-mount-LCWF3KPK.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/object-dialog-JFCVO3XR.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/qr-dialog-JXN7TAPX.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/settings-view-XJQ7WKV6.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/suggested-setups-dialog-YAP3OISD.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/task-dialog-PZMOKNLO.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/budget-section-card-ZJW7YTHU.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/chunk-A7GYNFDF.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/chunk-APM7GQQR.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/chunk-CWHLCD4F.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/chunk-DFNEXAVU.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/chunk-TY5VMGUH.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/dialog-mount-EX65KHSI.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/groups-section-card-H7274XMG.js create mode 100644 custom_components/maintenance_supporter/frontend/strategy/chunks/vacation-section-card-JZ3FWKSE.js 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)}