From 383e9ed04e963c3ff462788f2dc69d247e805cae Mon Sep 17 00:00:00 2001 From: Home Assistant Version Control Date: Fri, 21 Aug 2026 18:53:37 +0000 Subject: [PATCH] 197 files --- .ha_run.lock | 2 +- .storage/lovelace_resources | 2 +- .../config_flow_options_task_base.py | 8 + .../config_flow_trigger.py | 6 + .../maintenance_supporter/const.py | 15 +- .../maintenance_supporter/coordinator.py | 3 +- .../maintenance_supporter/diagnostics.py | 3 +- .../entity/triggers/__init__.py | 5 + .../entity/triggers/base_trigger.py | 7 +- .../entity/triggers/runtime.py | 22 +- .../entity/triggers/state_change.py | 244 ++++- .../entity/triggers/threshold.py | 19 +- .../adopt-problem-sensors-dialog.test.ts | 32 + .../__tests__/i18n-loader.test.ts | 23 +- .../task-dialog-trigger-roundtrip.test.ts | 17 + .../__tests__/task-documents.test.ts | 16 + .../adopt-problem-sensors-dialog.ts | 35 + .../components/battery-fleet-section.ts | 153 ++- .../frontend-src/components/confirm-dialog.ts | 4 +- .../components/documents-section.ts | 6 +- .../frontend-src/components/qr-dialog.ts | 12 +- .../components/storage-section-card.ts | 9 +- .../frontend-src/components/task-dialog.ts | 10 + .../frontend-src/components/task-documents.ts | 13 +- .../frontend-src/dialog-mount.ts | 4 +- .../frontend-src/helpers/calendar-bucket.ts | 4 + .../frontend-src/locales/cs.json | 5 + .../frontend-src/locales/da.json | 5 + .../frontend-src/locales/de.json | 5 + .../frontend-src/locales/en.json | 5 + .../frontend-src/locales/es.json | 5 + .../frontend-src/locales/fi.json | 5 + .../frontend-src/locales/fr.json | 5 + .../frontend-src/locales/hi.json | 5 + .../frontend-src/locales/hu.json | 5 + .../frontend-src/locales/it.json | 5 + .../frontend-src/locales/ja.json | 5 + .../frontend-src/locales/ko.json | 5 + .../frontend-src/locales/nb.json | 5 + .../frontend-src/locales/nl.json | 5 + .../frontend-src/locales/pl.json | 5 + .../frontend-src/locales/pt-br.json | 5 + .../frontend-src/locales/pt.json | 5 + .../frontend-src/locales/ru.json | 5 + .../frontend-src/locales/sv.json | 5 + .../frontend-src/locales/tr.json | 5 + .../frontend-src/locales/uk.json | 5 + .../frontend-src/locales/zh.json | 5 + .../frontend-src/maintenance-calendar-card.ts | 9 +- .../frontend-src/maintenance-card.ts | 18 +- .../frontend-src/maintenance-panel.ts | 22 +- .../frontend-src/renderers/history.ts | 6 +- .../frontend-src/styles.ts | 85 +- .../frontend/locales/cs.json | 5 + .../frontend/locales/da.json | 5 + .../frontend/locales/de.json | 5 + .../frontend/locales/en.json | 5 + .../frontend/locales/es.json | 5 + .../frontend/locales/fi.json | 5 + .../frontend/locales/fr.json | 5 + .../frontend/locales/hi.json | 5 + .../frontend/locales/hu.json | 5 + .../frontend/locales/it.json | 5 + .../frontend/locales/ja.json | 5 + .../frontend/locales/ko.json | 5 + .../frontend/locales/nb.json | 5 + .../frontend/locales/nl.json | 5 + .../frontend/locales/pl.json | 5 + .../frontend/locales/pt-br.json | 5 + .../frontend/locales/pt.json | 5 + .../frontend/locales/ru.json | 5 + .../frontend/locales/sv.json | 5 + .../frontend/locales/tr.json | 5 + .../frontend/locales/uk.json | 5 + .../frontend/locales/zh.json | 5 + .../frontend/maintenance-calendar-card.js | 201 ++-- .../frontend/maintenance-card.js | 873 ++++++++++++++---- .../frontend/maintenance-panel.js | 515 +++++++---- .../frontend/maintenance-strategy-shim.js | 4 +- ... adopt-problem-sensors-dialog-PA7GJBWV.js} | 74 +- .../frontend/panel-chunks/chunk-7PQSG54D.js | 2 - .../{chunk-TGQTH4TY.js => chunk-BBM5E3UT.js} | 2 +- .../{chunk-P4GKDOK3.js => chunk-FBQXAJIT.js} | 4 +- .../{chunk-4JER2GPD.js => chunk-FS5TZIDQ.js} | 59 +- .../frontend/panel-chunks/chunk-FU3XFEHD.js | 2 + .../frontend/panel-chunks/chunk-IMKP65C5.js | 2 + .../{chunk-4LG4SJUR.js => chunk-JIB2VNVZ.js} | 4 +- .../{chunk-H5KOKMZN.js => chunk-JY37H466.js} | 64 +- .../{chunk-EW357W3K.js => chunk-NOHIK75N.js} | 4 +- .../{chunk-LVFV6HYC.js => chunk-NWIQAC7B.js} | 50 +- .../{chunk-JPOJFRHB.js => chunk-OGUFLW7I.js} | 4 +- .../{chunk-4Q4MHZO7.js => chunk-VIX7PNOY.js} | 4 +- .../{chunk-TRENHXOS.js => chunk-VWJA3YLV.js} | 34 +- .../panel-chunks/complete-dialog-AXKKVV7I.js | 2 + .../panel-chunks/complete-dialog-DSUSVCOY.js | 2 - .../panel-chunks/dialog-mount-J5DT4DR4.js | 2 - .../panel-chunks/dialog-mount-QPVIPMM3.js | 2 + .../panel-chunks/object-dialog-67MRBDDZ.js | 2 - .../panel-chunks/object-dialog-PDILE4YD.js | 2 + .../panel-chunks/qr-dialog-BJHV7TQJ.js | 2 + .../panel-chunks/qr-dialog-DB6VBSEJ.js | 2 - ...-JHPB5SEH.js => settings-view-7UT7SSHN.js} | 4 +- ...js => suggested-setups-dialog-AFS42WM4.js} | 4 +- .../panel-chunks/task-dialog-5OFZSCJL.js | 2 + .../panel-chunks/task-dialog-EQJKXJ24.js | 2 - ...2C4.js => budget-section-card-DGQJXAH7.js} | 4 +- .../{chunk-DEWCQ6KU.js => chunk-4G4GJIX6.js} | 59 +- .../{chunk-U3QUK2QH.js => chunk-JBSWGFVZ.js} | 2 +- .../{chunk-Y6KVTASG.js => chunk-ZTNAKWY6.js} | 4 +- ...t-PO5M7UPG.js => dialog-mount-EPB72QLY.js} | 562 +++++------ ...VIK.js => groups-section-card-QABBF77T.js} | 4 +- ...D.js => vacation-section-card-AVXBOY3J.js} | 4 +- .../maintenance-dashboard-strategy.js | 6 +- .../helpers/battery_fleet.py | 118 ++- .../helpers/battery_fleet_setup.py | 110 ++- .../helpers/csv_handler.py | 3 +- .../maintenance_supporter/helpers/dates.py | 23 +- .../helpers/problem_sensors.py | 26 +- .../maintenance_supporter/helpers/sanitize.py | 6 +- .../helpers/saved_views.py | 4 +- .../helpers/trigger_fallback.py | 4 +- .../maintenance_supporter/manifest.json | 2 +- .../maintenance_supporter/sensor.py | 3 +- .../maintenance_supporter/strings.json | 8 +- .../translations/cs.json | 8 +- .../translations/da.json | 8 +- .../translations/de.json | 8 +- .../translations/en.json | 8 +- .../translations/es.json | 8 +- .../translations/fi.json | 8 +- .../translations/fr.json | 8 +- .../translations/hi.json | 8 +- .../translations/hu.json | 8 +- .../translations/it.json | 8 +- .../translations/ja.json | 8 +- .../translations/ko.json | 8 +- .../translations/nb.json | 8 +- .../translations/nl.json | 8 +- .../translations/pl.json | 8 +- .../translations/pt-BR.json | 8 +- .../translations/pt.json | 8 +- .../translations/ru.json | 8 +- .../translations/sv.json | 8 +- .../translations/tr.json | 8 +- .../translations/uk.json | 8 +- .../translations/zh-Hans.json | 8 +- .../websocket/__init__.py | 8 +- .../websocket/battery_fleet.py | 25 +- .../websocket/problem_sensors.py | 6 +- .../websocket/tasks_crud.py | 31 +- .../websocket/tasks_validation.py | 3 +- custom_components/powercalc/__init__.py | 9 +- .../powercalc/analytics/analytics.py | 3 + custom_components/powercalc/common.py | 46 +- custom_components/powercalc/config_flow.py | 4 +- custom_components/powercalc/const.py | 5 +- custom_components/powercalc/device_binding.py | 41 +- custom_components/powercalc/discovery.py | 848 +++++++++++------ .../powercalc/flow_helper/flows/library.py | 3 +- .../flow_helper/flows/virtual_power.py | 30 +- .../powercalc/group_include/filter.py | 8 +- .../powercalc/group_include/include.py | 36 +- custom_components/powercalc/helpers.py | 14 +- custom_components/powercalc/icons.json | 18 + custom_components/powercalc/manifest.json | 2 +- .../powercalc/power_profile/factory.py | 7 + .../powercalc/power_profile/library.py | 69 +- .../power_profile/loader/composite.py | 21 +- .../powercalc/power_profile/loader/local.py | 17 +- .../power_profile/loader/protocol.py | 26 +- .../powercalc/power_profile/loader/remote.py | 159 ++-- .../powercalc/power_profile/power_profile.py | 27 +- custom_components/powercalc/sensor.py | 21 +- .../powercalc/sensors/group/custom.py | 6 +- .../sensors/group/tracked_untracked.py | 13 +- custom_components/powercalc/sensors/power.py | 11 +- custom_components/powercalc/strategy/wled.py | 13 +- .../powercalc/translations/cs.json | 29 +- .../powercalc/translations/da.json | 29 +- .../powercalc/translations/de.json | 29 +- .../powercalc/translations/en.json | 29 +- .../powercalc/translations/es.json | 29 +- .../powercalc/translations/fi.json | 29 +- .../powercalc/translations/fr.json | 29 +- .../powercalc/translations/hu.json | 29 +- .../powercalc/translations/it.json | 29 +- .../powercalc/translations/nb.json | 29 +- .../powercalc/translations/nl.json | 29 +- .../powercalc/translations/pl.json | 29 +- .../powercalc/translations/pt-BR.json | 29 +- .../powercalc/translations/pt.json | 29 +- .../powercalc/translations/ro.json | 29 +- .../powercalc/translations/ru.json | 29 +- .../powercalc/translations/sk.json | 29 +- .../powercalc/translations/sv.json | 29 +- .../powercalc/translations/zh-Hans.json | 29 +- zigbee2mqtt/state.json | 50 +- 197 files changed, 4386 insertions(+), 1837 deletions(-) rename custom_components/maintenance_supporter/frontend/panel-chunks/{adopt-problem-sensors-dialog-N74EZ2V3.js => adopt-problem-sensors-dialog-PA7GJBWV.js} (52%) delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-7PQSG54D.js rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-TGQTH4TY.js => chunk-BBM5E3UT.js} (96%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-P4GKDOK3.js => chunk-FBQXAJIT.js} (73%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-4JER2GPD.js => chunk-FS5TZIDQ.js} (63%) create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-FU3XFEHD.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/chunk-IMKP65C5.js rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-4LG4SJUR.js => chunk-JIB2VNVZ.js} (97%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-H5KOKMZN.js => chunk-JY37H466.js} (90%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-EW357W3K.js => chunk-NOHIK75N.js} (93%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-LVFV6HYC.js => chunk-NWIQAC7B.js} (92%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-JPOJFRHB.js => chunk-OGUFLW7I.js} (93%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-4Q4MHZO7.js => chunk-VIX7PNOY.js} (86%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{chunk-TRENHXOS.js => chunk-VWJA3YLV.js} (62%) create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/complete-dialog-AXKKVV7I.js delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/complete-dialog-DSUSVCOY.js delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/dialog-mount-J5DT4DR4.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/dialog-mount-QPVIPMM3.js delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/object-dialog-67MRBDDZ.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/object-dialog-PDILE4YD.js create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/qr-dialog-BJHV7TQJ.js delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/qr-dialog-DB6VBSEJ.js rename custom_components/maintenance_supporter/frontend/panel-chunks/{settings-view-JHPB5SEH.js => settings-view-7UT7SSHN.js} (92%) rename custom_components/maintenance_supporter/frontend/panel-chunks/{suggested-setups-dialog-RYGUYL3X.js => suggested-setups-dialog-AFS42WM4.js} (78%) create mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/task-dialog-5OFZSCJL.js delete mode 100644 custom_components/maintenance_supporter/frontend/panel-chunks/task-dialog-EQJKXJ24.js rename custom_components/maintenance_supporter/frontend/strategy/chunks/{budget-section-card-KL2HJ2C4.js => budget-section-card-DGQJXAH7.js} (97%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{chunk-DEWCQ6KU.js => chunk-4G4GJIX6.js} (68%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{chunk-U3QUK2QH.js => chunk-JBSWGFVZ.js} (94%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{chunk-Y6KVTASG.js => chunk-ZTNAKWY6.js} (94%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{dialog-mount-PO5M7UPG.js => dialog-mount-EPB72QLY.js} (82%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{groups-section-card-42URNVIK.js => groups-section-card-QABBF77T.js} (97%) rename custom_components/maintenance_supporter/frontend/strategy/chunks/{vacation-section-card-LTTX54RD.js => vacation-section-card-AVXBOY3J.js} (97%) diff --git a/.ha_run.lock b/.ha_run.lock index 6bb78163..78e6f510 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 70, "version": 1, "ha_version": "2026.8.2", "start_ts": 1787246176.3005776} \ No newline at end of file +{"pid": 72, "version": 1, "ha_version": "2026.8.2", "start_ts": 1787338100.2662094} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index ae3a67a9..a0587ca9 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -315,7 +315,7 @@ "type": "module" }, { - "id": "ba07e776ada54bb899ab0dfd6f6f2dbd", + "id": "3dc0faac727c4411b93fbf34ae873574", "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", "type": "module" } diff --git a/custom_components/maintenance_supporter/config_flow_options_task_base.py b/custom_components/maintenance_supporter/config_flow_options_task_base.py index 526f74f1..a7acae73 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_base.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_base.py @@ -53,6 +53,14 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow): def _save_new_task(self) -> ConfigFlowResult: """Save the current task and return to init.""" from .config_flow_schedule import build_new_task_record + from .const import MAX_TASKS_PER_OBJECT + + # Same bound the WS create chokepoint enforces (drift audit 2026-08: + # this options-flow copy had no cap, so the flow could inflate + # ConfigEntry.data past the limit the API refuses). + if len(self.config_entry.data.get(CONF_TASKS, {})) >= MAX_TASKS_PER_OBJECT: + self._current_task = {} + return self._show_init_menu() task_id = uuid4().hex task_data = build_new_task_record( diff --git a/custom_components/maintenance_supporter/config_flow_trigger.py b/custom_components/maintenance_supporter/config_flow_trigger.py index 4590551a..9d508ed8 100644 --- a/custom_components/maintenance_supporter/config_flow_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_trigger.py @@ -681,6 +681,9 @@ class TriggerConfigMixin: if to_state: tc[CONF_TRIGGER_TO_STATE] = to_state tc[CONF_TRIGGER_TARGET_CHANGES] = user_input.get(CONF_TRIGGER_TARGET_CHANGES, 1) + # #136: the new state must HOLD this long before a change counts + # (0 = count immediately — some sensors pulse only briefly). + tc[CONF_TRIGGER_FOR_MINUTES] = user_input.get(CONF_TRIGGER_FOR_MINUTES, 0) _apply_recovery_flag(tc, user_input) _apply_combinator(tc, user_input) @@ -711,6 +714,9 @@ class TriggerConfigMixin: mode=selector.NumberSelectorMode.BOX, ) ), + vol.Optional(CONF_TRIGGER_FOR_MINUTES, default=0): selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX) + ), **_recovery_field(self._current_task.get("trigger_config")), } schema_fields.update(_entity_logic_field(self._current_task.get("trigger_config", {}).get("entity_ids", []))) diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index 74bc99dd..b1ba42e2 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -5,7 +5,7 @@ from __future__ import annotations import re from enum import StrEnum -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform DOMAIN = "maintenance_supporter" @@ -341,11 +341,18 @@ CONF_PARTS = "parts" # string literals in four files; find_fleet_entry, setup, the WS response # and the fleet exclusion all key on them). BATTERY_FLEET_OBJECT_FLAG = "battery_fleet" +# Marks the fleet's single aggregate task (detail view renders the battery +# section). Canonical home; helpers/battery_fleet_setup re-exports as TASK_FLAG. +BATTERY_FLEET_TASK_FLAG = "battery_fleet_task" BATTERY_FLEET_EXCLUDED = "battery_fleet_excluded" # #135: entity_ids manually ADDED to the fleet — batteries the discovery # heuristics miss (no device_class, odd naming, self-charging filter). An # include bypasses the heuristic and #107 filters, never the dedupe. BATTERY_FLEET_INCLUDED = "battery_fleet_included" +# #135 follow-up: fleet-wide opt-in that keeps self-charging devices (phones, +# vacuums, smart rings — the #107 filter) in the roster as rechargeables, so +# "needs a recharge" can notify like "needs a swap". Default off: #107 stays. +BATTERY_FLEET_TRACK_SELF_CHARGING = "battery_fleet_track_self_charging" # Task-side link: task["consumes_parts"] = [{"part_id", "quantity"}] — a # completion decrements each linked part's stock. CONF_TASK_CONSUMES_PARTS = "consumes_parts" @@ -375,6 +382,7 @@ CONF_TASK_NFC_TAG = "nfc_tag_id" # v2.20 (#83): display unit for `reading`-type tasks ("kWh", "m³", ...). CONF_TASK_READING_UNIT = "reading_unit" MAX_READING_UNIT_LENGTH = 32 +MAX_NFC_TAG_LENGTH = 256 CONF_TASK_INTERVAL_ANCHOR = "interval_anchor" CONF_TASK_SCHEDULE_TIME = "schedule_time" CONF_TASK_PRIORITY = "priority" @@ -704,6 +712,11 @@ MAX_GROUP_TASK_REFS = 200 MAX_ID_LENGTH = 64 # entry_id, task_id, group_id (uuid hex = 32) MAX_DATE_LENGTH = 20 # ISO 8601 date strings (e.g. 2026-04-21) MAX_ENTITY_ID_LENGTH = 255 # HA entity_id max + +# "No usable reading" state pair — 13 call sites hand-typed the tuple before +# the 2026-08 drift audit. Built from HA's own constants so it can never +# drift from core. +UNAVAILABLE_STATES = (STATE_UNAVAILABLE, STATE_UNKNOWN) MAX_ENTITY_SLUG_LENGTH = 64 # task entity_slug MAX_INTERVAL_DAYS = 3650 # 10 years — caps date arithmetic overflow # Import payload byte caps. CSV is flat (one row per task) so 1 MB is ample; diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index d6cd701c..2adee15b 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -42,6 +42,7 @@ from .const import ( SIGNAL_TASK_RESET, STARTUP_GRACE_PERIOD_SECONDS, TRIGGER_COMPLETION_COOLDOWN_SECONDS, + UNAVAILABLE_STATES, HistoryEntryType, MaintenanceStatus, ScheduleType, @@ -518,7 +519,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): issue_id = f"missing_trigger_{self.entry.entry_id}_{task_id}_{trigger_entity_id}" state = self.hass.states.get(trigger_entity_id) - if state is not None and state.state not in ("unavailable", "unknown"): + if state is not None and state.state not in UNAVAILABLE_STATES: # Entity exists and is available self._entity_missing_refresh_count.pop(entity_key, None) self._entity_unavailable_logged.pop(entity_key, None) diff --git a/custom_components/maintenance_supporter/diagnostics.py b/custom_components/maintenance_supporter/diagnostics.py index 7cd68102..b9a776cf 100644 --- a/custom_components/maintenance_supporter/diagnostics.py +++ b/custom_components/maintenance_supporter/diagnostics.py @@ -13,6 +13,7 @@ from .const import ( CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, + UNAVAILABLE_STATES, MaintenanceStatus, TriggerEntityState, ) @@ -178,7 +179,7 @@ def _check_trigger_status(hass: HomeAssistant, data: Mapping[str, Any]) -> list[ if state is None: entity_health = TriggerEntityState.MISSING - elif state.state in ("unavailable", "unknown"): + elif state.state in UNAVAILABLE_STATES: entity_health = TriggerEntityState.UNAVAILABLE else: entity_health = TriggerEntityState.AVAILABLE diff --git a/custom_components/maintenance_supporter/entity/triggers/__init__.py b/custom_components/maintenance_supporter/entity/triggers/__init__.py index 80fa171d..d274e615 100644 --- a/custom_components/maintenance_supporter/entity/triggers/__init__.py +++ b/custom_components/maintenance_supporter/entity/triggers/__init__.py @@ -103,6 +103,11 @@ def _inject_per_entity_state(config: dict[str, Any], entity_state: dict[str, Any elif trigger_type == TriggerType.STATE_CHANGE: if "change_count" in entity_state: config["trigger_change_count"] = entity_state["change_count"] + # #136: a hold window that was open when HA went down. + if "pending_since" in entity_state: + config["trigger_state_pending_since"] = entity_state["pending_since"] + if "pending_state" in entity_state: + config["trigger_state_pending_state"] = entity_state["pending_state"] elif trigger_type == TriggerType.THRESHOLD: tes = entity_state.get("threshold_exceeded_since") if tes: diff --git a/custom_components/maintenance_supporter/entity/triggers/base_trigger.py b/custom_components/maintenance_supporter/entity/triggers/base_trigger.py index 9b072e10..ccd90926 100644 --- a/custom_components/maintenance_supporter/entity/triggers/base_trigger.py +++ b/custom_components/maintenance_supporter/entity/triggers/base_trigger.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from ...const import ( EVENT_TRIGGER_ACTIVATED, EVENT_TRIGGER_DEACTIVATED, + UNAVAILABLE_STATES, ) _LOGGER = logging.getLogger(__name__) @@ -80,7 +81,7 @@ class BaseTrigger(ABC): self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_change_event) # If state is unknown/unavailable, schedule a retry - if state.state in ("unavailable", "unknown"): + if state.state in UNAVAILABLE_STATES: _LOGGER.info( "Trigger entity %s is '%s' — will retry evaluation in 30s", self.entity_id, @@ -115,7 +116,7 @@ class BaseTrigger(ABC): """Re-check entity state after a delay.""" self._unsub_retry = None state = self.hass.states.get(self.entity_id) - if state is None or state.state in ("unavailable", "unknown"): + if state is None or state.state in UNAVAILABLE_STATES: _LOGGER.debug( "Trigger entity %s still %s after retry", self.entity_id, @@ -176,7 +177,7 @@ class BaseTrigger(ABC): # trigger entity is surfaced via the missing_trigger_entity repair # flow instead. (Numeric-only triggers: any non-numeric value below # is likewise ignored via the _get_numeric_value None check.) - if new_state.state in ("unavailable", "unknown"): + if new_state.state in UNAVAILABLE_STATES: if not self._logged_unavailable: _LOGGER.warning( "Trigger entity %s became %s — keeping last trigger state", diff --git a/custom_components/maintenance_supporter/entity/triggers/runtime.py b/custom_components/maintenance_supporter/entity/triggers/runtime.py index 91a4bc99..ea578723 100644 --- a/custom_components/maintenance_supporter/entity/triggers/runtime.py +++ b/custom_components/maintenance_supporter/entity/triggers/runtime.py @@ -24,6 +24,8 @@ from homeassistant.helpers.event import ( async_track_time_interval, ) +from ...const import UNAVAILABLE_STATES + if TYPE_CHECKING: from ...sensor import MaintenanceSensor from homeassistant.util import dt as dt_util @@ -57,15 +59,15 @@ class RuntimeTrigger(BaseTrigger): self._target_hours: float = trigger_config.get("trigger_runtime_hours", 100.0) self._accumulated_seconds: float = trigger_config.get("trigger_accumulated_seconds", 0.0) - # Restore on_since timestamp for restart recovery + # Restore on_since timestamp for restart recovery. parse_persisted_utc + # coerces a naive legacy payload to UTC — dt_util.parse_datetime kept + # it naive here, and `utcnow() - naive` raises TypeError in the + # elapsed math (drift audit 2026-08; the sibling triggers coerced). + from ...helpers.dates import parse_persisted_utc + on_since_str = trigger_config.get("trigger_on_since") - self._on_since: str | None = None # ISO string stored for persistence - self._on_since_dt: datetime | None = None # parsed datetime for calculation - if on_since_str: - parsed = dt_util.parse_datetime(on_since_str) - if parsed: - self._on_since = on_since_str - self._on_since_dt = parsed + self._on_since_dt: datetime | None = parse_persisted_utc(on_since_str) + self._on_since: str | None = on_since_str if self._on_since_dt is not None else None # Custom ON states (default: on, 1, true) custom_on = trigger_config.get("trigger_on_states") @@ -79,7 +81,7 @@ class RuntimeTrigger(BaseTrigger): async def async_setup(self) -> None: """Set up runtime trigger with state restoration.""" state = self.hass.states.get(self.entity_id) - if state is None or state.state in ("unavailable", "unknown"): + if state is None or state.state in UNAVAILABLE_STATES: # No USABLE state yet (#131 family): "unavailable" must not read # as OFF — a running device whose sensor merely connects late # would lose its restored on_since anchor and undercount. Keep @@ -189,7 +191,7 @@ class RuntimeTrigger(BaseTrigger): old_val = self._tracked_value(old_state) # Handle unavailable/unknown — pause accumulation - if raw_state in ("unavailable", "unknown"): + if raw_state in UNAVAILABLE_STATES: if self._on_since_dt is not None: self._accumulate_elapsed() self._on_since_dt = None diff --git a/custom_components/maintenance_supporter/entity/triggers/state_change.py b/custom_components/maintenance_supporter/entity/triggers/state_change.py index 3cde2dff..8583dee0 100644 --- a/custom_components/maintenance_supporter/entity/triggers/state_change.py +++ b/custom_components/maintenance_supporter/entity/triggers/state_change.py @@ -3,13 +3,18 @@ from __future__ import annotations import logging +from datetime import datetime from typing import TYPE_CHECKING, Any -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import ( EventStateChangedData, + async_call_later, async_track_state_change_event, ) +from homeassistant.util import dt as dt_util + +from ...const import UNAVAILABLE_STATES if TYPE_CHECKING: from ...sensor import MaintenanceSensor @@ -34,6 +39,12 @@ class StateChangeTrigger(BaseTrigger): # Setup saw no usable state -> reconcile on the first real one (#131). # Class default so hand-built test instances inherit it. _needs_latch_reconcile: bool = False + # #136 hold-window state — class defaults for the same reason. + _for_minutes: int = 0 + _pending_state: str | None = None + _pending_since: str | None = None + _interrupted_pending: str | None = None + _timer_cancel: CALLBACK_TYPE | None = None def __init__( self, @@ -58,6 +69,29 @@ class StateChangeTrigger(BaseTrigger): self._last_state: str | None = None self._needs_latch_reconcile = False + # #136: a transition only counts once the NEW state has HELD for this + # long. 0 (the default) counts immediately — deliberately, because some + # sensors express a real event only as a brief pulse; the filter is an + # opt-in for the flappy ones. Applies to BOTH modes: the single-shot + # alarm latch (target_changes == 1, the reporter's vacuum problem + # sensors glitching for seconds at night) and the cycle counter + # (a flicker is not a wash cycle). + self._for_minutes: int = int(trigger_config.get("trigger_for_minutes", 0) or 0) + self._timer_cancel: CALLBACK_TYPE | None = None + # A window cut short by an unavailability blip — the only case that + # may re-open on recovery (see _handle_state_transition). + self._interrupted_pending: str | None = None + # The state currently waiting out the hold window (None = no window). + self._pending_state: str | None = None + self._pending_since: str | None = None + # Persisted pending window from before a restart (consumed in setup). + from ...helpers.dates import parse_persisted_utc + + self._restored_pending_state: str | None = trigger_config.get("trigger_state_pending_state") + self._restored_pending_dt: datetime | None = parse_persisted_utc(trigger_config.get("trigger_state_pending_since")) + if self._restored_pending_dt is None: + self._restored_pending_state = None + async def async_setup(self) -> None: """Set up state change trigger. @@ -66,7 +100,7 @@ class StateChangeTrigger(BaseTrigger): appears (old_state=None), so the trigger will self-heal automatically. """ state = self.hass.states.get(self.entity_id) - if state is None or state.state in ("unavailable", "unknown"): + if state is None or state.state in UNAVAILABLE_STATES: # No USABLE state yet — the #131 family: trigger setup races both # the entity's registration AND its device readiness (a Zigbee / # Z-Wave problem sensor restores as unavailable long before it @@ -85,6 +119,7 @@ class StateChangeTrigger(BaseTrigger): self._last_state = state.state self._reconcile_persisted_latch(state.state) + self._resume_pending_window(state.state) # Register state change listener (override base: we handle events differently) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) @@ -119,8 +154,7 @@ class StateChangeTrigger(BaseTrigger): self._change_count = 0 self._current_value = 0.0 self._triggered = False - if self.hass.is_running: - self.hass.async_create_task(self._persist_change_count()) + self._persist_runtime_soon() self.entity.async_update_trigger_state( is_triggered=False, current_value=0.0, @@ -134,6 +168,113 @@ class StateChangeTrigger(BaseTrigger): trigger_entity_id=self.entity_id, ) + def _resume_pending_window(self, live_state: str) -> None: + """Resume (or discard) a hold window persisted before a restart (#136). + + Mirrors the threshold trigger's exceeded-since recovery: the wall-clock + anchor survives the restart, so a state that kept holding through the + downtime commits immediately once the window has fully elapsed, and + otherwise the timer resumes with the remaining duration. A live state + that no longer matches the anchored one discards the window. + """ + restored_state, restored_dt = self._restored_pending_state, self._restored_pending_dt + self._restored_pending_state = None + self._restored_pending_dt = None + if self._for_minutes <= 0 or restored_dt is None or restored_state is None or self._triggered: + return + if _norm_state(live_state) != _norm_state(restored_state): + self._persist_runtime_soon() + return + elapsed = (dt_util.utcnow() - restored_dt).total_seconds() + if elapsed >= self._for_minutes * 60: + _LOGGER.debug( + "State hold recovery: elapsed %.0fs >= %ds, committing immediately: %s", + elapsed, + self._for_minutes * 60, + self.entity_id, + ) + self._commit_transition(live_state, None) + return + self._pending_state = restored_state + self._pending_since = restored_dt.isoformat() + remaining = max(self._for_minutes * 60 - elapsed, 0) + _LOGGER.debug("State hold recovery: %.0fs remaining: %s", remaining, self.entity_id) + self._start_hold_timer(remaining_seconds=remaining) + + def _start_pending(self, new_val: str) -> None: + """(Re)open the hold window for *new_val* — commits when the timer fires.""" + self._cancel_timer() + self._pending_state = new_val + self._pending_since = dt_util.utcnow().isoformat() + self._persist_runtime_soon() + self._start_hold_timer() + + def _start_hold_timer(self, remaining_seconds: float | None = None) -> None: + self._cancel_timer() + duration = remaining_seconds if remaining_seconds is not None else self._for_minutes * 60 + + @callback + def _timer_fired(_now: datetime) -> None: + pending = self._pending_state + self._pending_state = None + self._pending_since = None + self._timer_cancel = None + if pending is None: + return + # Safety net: only commit while the state still holds. + live = self.hass.states.get(self.entity_id) + if live is None or _norm_state(live.state) != _norm_state(pending): + self._persist_runtime_soon() + return + _LOGGER.debug( + "State hold timer fired: %s held %r for %d min", + self.entity_id, + pending, + self._for_minutes, + ) + self._commit_transition(pending, None) + + self._timer_cancel = async_call_later(self.hass, duration, _timer_fired) + + def _clear_pending(self) -> None: + """Abandon the hold window (state moved on before it elapsed).""" + if self._pending_state is None and self._timer_cancel is None: + return + self._cancel_timer() + self._pending_state = None + self._pending_since = None + self._persist_runtime_soon() + + def _cancel_timer(self) -> None: + if self._timer_cancel is not None: + self._timer_cancel() + self._timer_cancel = None + + def _commit_transition(self, new_val: str, old_val: str | None) -> None: + """Count one matching transition (immediately, or after its hold).""" + self._pending_state = None + self._pending_since = None + self._change_count += 1 + self._current_value = float(self._change_count) + self._persist_runtime_soon() + _LOGGER.debug( + "State change counted: %s (%s -> %s) count=%d/%d", + self.entity_id, + old_val if old_val is not None else "", + new_val, + self._change_count, + self._target_changes, + ) + + was_triggered = self._triggered + is_triggered = self._change_count >= self._target_changes + self._triggered = is_triggered + + if is_triggered and not was_triggered: + self._on_trigger_activated(float(self._change_count)) + elif not is_triggered and was_triggered: + self._on_trigger_deactivated(float(self._change_count)) + @callback def _handle_state_transition(self, event: Event[EventStateChangedData]) -> None: """Handle state transition and count matching changes.""" @@ -158,7 +299,7 @@ class StateChangeTrigger(BaseTrigger): # reconcile the persisted latch against it (issue #131): when the # entity restores AFTER our setup, this appearance is the first # moment the latch can be checked against reality. - if new_val not in ("unavailable", "unknown"): + if new_val not in UNAVAILABLE_STATES: self._needs_latch_reconcile = False self._last_state = new_val self._reconcile_persisted_latch(new_val) @@ -167,7 +308,14 @@ class StateChangeTrigger(BaseTrigger): old_val = old_state.state # Handle unavailable/unknown with log-once pattern - if new_val in ("unavailable", "unknown"): + if new_val in UNAVAILABLE_STATES: + # #136: an unavailability blip is not "the state held" — abandon + # the hold window, but REMEMBER it: only a window that was + # actually running may re-open when the entity comes back (else a + # blip on a long-settled state would count a phantom transition). + if self._pending_state is not None: + self._interrupted_pending = self._pending_state + self._clear_pending() if not self._logged_unavailable: _LOGGER.warning( "Trigger entity %s became %s", @@ -185,6 +333,19 @@ class StateChangeTrigger(BaseTrigger): new_val, ) self._logged_unavailable = False + # #136: a window was running when the blip hit and the state came + # back unchanged — restart it (fresh clock; the normal transition + # path below cannot, because effective_old equals new_val here). + interrupted = self._interrupted_pending + self._interrupted_pending = None + if ( + self._for_minutes > 0 + and not self._triggered + and self._pending_state is None + and interrupted is not None + and _norm_state(new_val) == _norm_state(interrupted) + ): + self._start_pending(new_val) # First REAL state after a setup that saw none/unavailable (#131 # family): reconcile the persisted latch against it instead of @@ -199,9 +360,16 @@ class StateChangeTrigger(BaseTrigger): # Use _last_state as fallback when old_val is unavailable/unknown effective_old = old_val - if old_val in ("unavailable", "unknown") and self._last_state is not None: + if old_val in UNAVAILABLE_STATES and self._last_state is not None: effective_old = self._last_state + # #136: any real state movement means the previous state did NOT hold + # — abandon a running hold window (a matching transition right below + # opens a fresh one) and invalidate a blip-interruption marker. + if effective_old != new_val: + self._interrupted_pending = None + self._clear_pending() + # Check if transition matches pattern matches = True if self._from_state is not None and _norm_state(effective_old) != self._from_state: @@ -210,28 +378,11 @@ class StateChangeTrigger(BaseTrigger): matches = False if matches and effective_old != new_val: - self._change_count += 1 - self._current_value = float(self._change_count) - # Persist change count to survive restarts - if self.hass.is_running: - self.hass.async_create_task(self._persist_change_count()) - _LOGGER.debug( - "State change counted: %s (%s -> %s) count=%d/%d", - self.entity_id, - old_val, - new_val, - self._change_count, - self._target_changes, - ) - - was_triggered = self._triggered - is_triggered = self._change_count >= self._target_changes - self._triggered = is_triggered - - if is_triggered and not was_triggered: - self._on_trigger_activated(float(self._change_count)) - elif not is_triggered and was_triggered: - self._on_trigger_deactivated(float(self._change_count)) + if self._for_minutes > 0: + # #136: the transition only counts once new_val has held. + self._start_pending(new_val) + else: + self._commit_transition(new_val, old_val) # Latch recovery: a single-shot state alarm (target_changes == 1 — an # adopted problem sensor or an appliance event) clears when the entity @@ -247,8 +398,7 @@ class StateChangeTrigger(BaseTrigger): ): self._change_count = 0 self._current_value = 0.0 - if self.hass.is_running: - self.hass.async_create_task(self._persist_change_count()) + self._persist_runtime_soon() self._triggered = False self._on_trigger_deactivated(0.0) @@ -268,19 +418,39 @@ class StateChangeTrigger(BaseTrigger): """Reset the change counter (after maintenance).""" self._change_count = 0 self._current_value = 0.0 - if self.hass.is_running: - self.hass.async_create_task(self._persist_change_count()) + self._persist_runtime_soon() _LOGGER.debug("State change counter reset: %s", self.entity_id) - async def _persist_change_count(self) -> None: - """Persist change count to the Store for survival across restarts.""" + def _persist_runtime_soon(self) -> None: + if self.hass.is_running: + self.hass.async_create_task(self._persist_runtime()) + + async def _persist_runtime(self) -> None: + """Persist the full runtime dict (count + hold window) to the Store. + + Always the COMPLETE dict: set_trigger_runtime replaces per-entity + state wholesale, so a partial write would drop the other half. + """ + data: dict[str, Any] = {"change_count": self._change_count} + if self._pending_since is not None and self._pending_state is not None: + data["pending_since"] = self._pending_since + data["pending_state"] = self._pending_state await self._coordinator.async_persist_trigger_runtime( self._task_id, - {"change_count": self._change_count}, + data, entity_id=self.entity_id, ) + async def async_teardown(self) -> None: + """Clean up the hold timer on teardown.""" + self._cancel_timer() + await super().async_teardown() + def reset(self) -> None: - """Reset trigger and counter.""" + """Reset trigger, counter and any running hold window.""" super().reset() + self._cancel_timer() + self._pending_state = None + self._pending_since = None + self._interrupted_pending = None self.reset_count() diff --git a/custom_components/maintenance_supporter/entity/triggers/threshold.py b/custom_components/maintenance_supporter/entity/triggers/threshold.py index 4d553e65..76d86bd6 100644 --- a/custom_components/maintenance_supporter/entity/triggers/threshold.py +++ b/custom_components/maintenance_supporter/entity/triggers/threshold.py @@ -48,22 +48,11 @@ class ThresholdTrigger(BaseTrigger): self._timer_cancel: CALLBACK_TYPE | None = None # Restore persisted exceeded-since timestamp (survives HA restarts) - exceeded_since = trigger_config.get("trigger_threshold_exceeded_since") - self._exceeded_since: str | None = None - self._exceeded_since_dt: datetime | None = None - if exceeded_since: - try: - parsed = datetime.fromisoformat(exceeded_since) - # Older payloads may be naive — assume UTC since live writes - # use dt_util.utcnow().isoformat() (TZ-aware). - if parsed.tzinfo is None: - from datetime import UTC + from ...helpers.dates import parse_persisted_utc - parsed = parsed.replace(tzinfo=UTC) - self._exceeded_since_dt = parsed - self._exceeded_since = exceeded_since - except (ValueError, TypeError): - pass + exceeded_since = trigger_config.get("trigger_threshold_exceeded_since") + self._exceeded_since_dt: datetime | None = parse_persisted_utc(exceeded_since) + self._exceeded_since: str | None = exceeded_since if self._exceeded_since_dt is not None else None def _value_exceeds_threshold(self, value: float) -> bool: """Check if the value exceeds configured thresholds.""" diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts index ce128f12..6685a974 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts @@ -62,3 +62,35 @@ describe("adopt-problem-sensors dialog: suggested part", () => { expect(byId["binary_sensor.pump_problem"].part_id).to.equal(undefined); }); }); + +describe("adopt-problem-sensors dialog: flicker filter (#136)", () => { + it("defaults to 0 and then omits for_minutes from the payload", async () => { + const { el, sent } = await mountOpen(); + const input = el.shadowRoot!.querySelector(".for-input")!; + expect(input, "the for-minutes field renders").to.exist; + expect(input.value).to.equal("0"); + el.shadowRoot!.querySelectorAll("ha-button")[1].click(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + const adopt = sent.find((m) => m.type === "maintenance_supporter/problem_sensors/adopt")! as { + selections: Array<{ for_minutes?: number }>; + }; + expect(adopt.selections.every((s) => s.for_minutes === undefined)).to.equal(true); + }); + + it("forwards the entered minutes on every selection", async () => { + const { el, sent } = await mountOpen(); + const input = el.shadowRoot!.querySelector(".for-input")!; + input.value = "10"; + input.dispatchEvent(new Event("input")); + await el.updateComplete; + el.shadowRoot!.querySelectorAll("ha-button")[1].click(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + const adopt = sent.find((m) => m.type === "maintenance_supporter/problem_sensors/adopt")! as { + selections: Array<{ for_minutes?: number }>; + }; + expect(adopt.selections.length).to.be.greaterThan(0); + expect(adopt.selections.every((s) => s.for_minutes === 10)).to.equal(true); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/i18n-loader.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/i18n-loader.test.ts index 5fc9a88b..d9a45eb8 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/i18n-loader.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/i18n-loader.test.ts @@ -13,7 +13,7 @@ * the no-fetch fast paths of ensureLocale/isLocaleLoaded. */ import { expect } from "@open-wc/testing"; -import { t, isLocaleLoaded, ensureLocale } from "../styles"; +import { t, isLocaleLoaded, ensureLocale, seedEnglish } from "../styles"; describe("i18n runtime loader", () => { it("serves bundled English synchronously", () => { @@ -60,4 +60,25 @@ describe("i18n runtime loader", () => { delete g!.store.pt; } }); + + it("seedEnglish MERGES so a stale first-loader cannot shadow newer keys (#135 regression)", () => { + // After an update, a cached app shell can load an OLD card bundle first; + // its EN table lacks every key the fresh panel introduced. First-wins + // seeding froze that stale table and the panel rendered raw keys + // ("BATTERY_FLEET_ADD"). The merge keeps existing entries (steady state + // unchanged) but lets every bundle contribute the keys it knows. + const g = (window as unknown as { + __msLocales: { store: Record> }; + }).__msLocales; + const original = g.store.en; + try { + // Simulate the stale bundle having seeded first with a tiny old table. + g.store.en = { loading: "Old loading text" }; + seedEnglish({ loading: "Fresh loading text", brand_new_key: "New feature label" }); + expect(t("loading", "en"), "existing entries win conflicts").to.equal("Old loading text"); + expect(t("brand_new_key", "en"), "newer keys are contributed, never raw").to.equal("New feature label"); + } finally { + g.store.en = original; + } + }); }); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts index cc933ef8..cb2dcad9 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts @@ -162,6 +162,23 @@ describe("task-dialog trigger_config roundtrip closure (#103 class)", () => { }); }); + it("state_change: the hold filter (for_minutes, #136) survives the roundtrip", async () => { + const tc = await saveRoundtrip({ + type: "state_change", + entity_id: "binary_sensor.vacuum_problem", + entity_ids: ["binary_sensor.vacuum_problem"], + trigger_to_state: "on", + trigger_target_changes: 1, + trigger_for_minutes: 10, + auto_complete_on_recovery: true, + }); + expect(tc).to.deep.include({ + type: "state_change", + trigger_to_state: "on", + trigger_for_minutes: 10, + }); + }); + it("compound: per-condition attribute/baseline/entity_logic survive via carry", async () => { const tc = await saveRoundtrip({ type: "compound", diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts index 8456daed..6616ceed 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts @@ -122,6 +122,22 @@ describe("task-documents", () => { window.open = orig; } }); + + it("refuses to window.open a non-http(s) weblink (defense-in-depth, XSS)", async () => { + const EVIL = { id: "d4", kind: "weblink", title: "Evil", url: "javascript:alert(document.cookie)", tags: [], task_ids: ["t1"] }; + const orig = window.open; + let opened = "NOT_CALLED"; + window.open = ((u: string) => { opened = u; return null; }) as typeof window.open; + try { + const { el } = await mount(true, [EVIL]); + const info = el.shadowRoot!.querySelector(".tdoc-row .tdoc-info")!; + info.click(); + await el.updateComplete; + expect(opened, "a javascript: weblink must never reach window.open").to.equal("NOT_CALLED"); + } finally { + window.open = orig; + } + }); }); // ── part mode (v2.26): same component, linking via part_ids ───────────────── diff --git a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts index 3cb6ce2b..89f10d48 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts @@ -50,6 +50,9 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { @state() private _selected: Set = new Set(); @state() private _users: HAUser[] = []; @state() private _responsible = ""; + // #136: minutes the problem must persist before the created task triggers + // (one field applied to every selection). "0" = react to the first flicker. + @state() private _forMinutes = "0"; private _localeReady = false; private _userService: UserService | null = null; @@ -72,6 +75,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { this._sensors = []; this._selected = new Set(); this._responsible = ""; + this._forMinutes = "0"; try { if (!this._userService) this._userService = new UserService(this.hass); else this._userService.updateHass(this.hass); @@ -126,6 +130,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { device_id: s.device_id ?? undefined, part_id: s.suggested_part_id ?? undefined, responsible_user_id: this._responsible || undefined, + for_minutes: parseInt(this._forMinutes, 10) > 0 ? parseInt(this._forMinutes, 10) : undefined, })); const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/problem_sensors/adopt", @@ -214,6 +219,23 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { `} + ${!this._loading && this._sensors.length > 0 + ? html` + +
${t("adopt_for_minutes_hint", L)}
+ ` + : nothing} + ${!this._loading && this._sensors.length > 0 && this._users.length > 0 ? html`