"""WebSocket handlers for subscribe, statistics, settings, and budget.""" from __future__ import annotations import logging import math from collections.abc import Callable, Mapping from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_call_later from ..const import ( BUDGET_CURRENCIES, CONF_ACTION_COMPLETE_ENABLED, CONF_ACTION_SKIP_ENABLED, CONF_ACTION_SNOOZE_ENABLED, CONF_ADMIN_PANEL_USER_IDS, CONF_ADVANCED_ADAPTIVE, CONF_ADVANCED_BUDGET, CONF_ADVANCED_CHECKLISTS, CONF_ADVANCED_COMPLETION_ACTIONS, CONF_ADVANCED_ENVIRONMENTAL, CONF_ADVANCED_GROUPS, CONF_ADVANCED_PREDICTIONS, CONF_ADVANCED_SCHEDULE_TIME, CONF_ADVANCED_SEASONAL, CONF_ARCHIVE_ONEOFF_DAYS, CONF_BATTERY_AUTO_RECORD_RECOVERY, CONF_BATTERY_LIFETIME_MONTHS, CONF_BATTERY_LOW_PERCENT, CONF_BATTERY_RECOVERED_PERCENT, CONF_BUDGET_ALERT_THRESHOLD, CONF_BUDGET_ALERTS_ENABLED, CONF_BUDGET_CURRENCY, CONF_BUDGET_MONTHLY, CONF_BUDGET_YEARLY, CONF_CURRENCY_DECIMALS, CONF_DEFAULT_CONSUMABLE_THRESHOLD, CONF_DEFAULT_WARNING_DAYS, CONF_DELETE_ARCHIVED_ONEOFF_DAYS, CONF_DISABLED_TEMPLATE_IDS, CONF_INSTALL_ASSIST_SENTENCES, CONF_MAX_NOTIFICATIONS_PER_DAY, CONF_MEMBER_DISPLAY, CONF_NOTIFICATION_BUNDLE_THRESHOLD, CONF_NOTIFICATION_BUNDLING_ENABLED, CONF_NOTIFICATION_TITLE_STYLE, CONF_NOTIFICATIONS_ENABLED, CONF_NOTIFY_COMPLETED, CONF_NOTIFY_DUE_SOON_ENABLED, CONF_NOTIFY_DUE_SOON_INTERVAL, CONF_NOTIFY_EVENT_ONLY, CONF_NOTIFY_EXTRA_DATA, CONF_NOTIFY_OVERDUE_ENABLED, CONF_NOTIFY_OVERDUE_INTERVAL, CONF_NOTIFY_SCOPE_VIEW_ID, CONF_NOTIFY_SERVICE, CONF_NOTIFY_TRIGGERED_ENABLED, CONF_NOTIFY_TRIGGERED_INTERVAL, CONF_OBJECTS_TABLE_COLUMNS, 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, CONF_REF_NUMBERS_IN_LISTS, CONF_REMINDER_LEAD_DAYS, CONF_ROW_ACTION_NOTICE, CONF_ROW_ACTION_STYLE, CONF_SHOPPING_LIST_ENTITY, CONF_SNOOZE_DURATION_HOURS, CONF_WARRANTY_REMINDER_DAYS, CONF_WARRANTY_REMINDER_ENABLED, CONF_WEEKLY_DIGEST_ENABLED, DEFAULT_BUDGET_CURRENCY, DEFAULT_OBJECTS_TABLE_COLUMNS, DOMAIN, GLOBAL_UNIQUE_ID, KNOWN_OBJECT_TABLE_COLUMNS, MAX_PANEL_TITLE_LENGTH, MAX_REMINDER_LEADS, SIGNAL_NEW_OBJECT_ENTRY, TIME_HHMMSS_PATTERN, ) from ..helpers.aggregate import compute_status_counts from ..helpers.global_options import get_global_options from ..helpers.notify_targets import build_notify_targets from ..helpers.settings_registry import ( ALLOWED_SETTING_KEYS, FLOAT_RANGES, INT_RANGES, STR_MAX_LENGTHS, setting_default, ) from . import ( ID_FIELD, _build_object_response, _get_global_entry, _get_object_entries, _get_runtime_data, _load_global_options, _save_global_options, ) _LOGGER = logging.getLogger(__name__) # Keys accepted by global/update + their range/cap tables are derived from the # single settings registry (helpers/settings_registry) so they can't drift from # each other or from the options-flow selectors that share the same specs. _ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS def _opt(options: Mapping[str, Any], key: str) -> Any: """A global option, or its registry default when unset — the one fallback every echoed setting goes through (defaults live in settings_registry).""" return options.get(key, setting_default(key)) def _currency_block(options: Mapping[str, Any]) -> dict[str, Any]: """How amounts are displayed: currency code, symbol and decimal places.""" code = str(_opt(options, CONF_BUDGET_CURRENCY)) return { "currency": code, "currency_symbol": BUDGET_CURRENCIES.get(code, BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY]), "currency_decimals": int(_opt(options, CONF_CURRENCY_DECIMALS)), } def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]: """The lifetime table as Settings shows it — fleet types first.""" try: # Readable "Manufacturer Model" per pool key for the learned rows. from homeassistant.helpers import device_registry as dr from ..helpers.battery_fleet import discover_battery_types, read_batteries from ..helpers.battery_lifetime import lifetime_catalog names: dict[str, str] = {} dev_reg = dr.async_get(hass) for bat in read_batteries(hass): if bat.model_key and bat.model_key not in names: for device in dev_reg.devices.values(): manufacturer = str(getattr(device, "manufacturer", None) or "") model = str(getattr(device, "model", None) or getattr(device, "model_id", None) or "") key = f"{manufacturer.strip().lower()}|{model.strip().lower()}" if key == bat.model_key: names[bat.model_key] = " ".join(x for x in (manufacturer, model) if x) break return lifetime_catalog(hass, list(discover_battery_types(hass)), model_names=names) except Exception: # noqa: BLE001 - a settings read must never fail on the fleet return [] def _search_url_default(hass: HomeAssistant) -> str: """The shopping-search template in effect when none is set (D#182: country first, UI language second) — the Settings input's placeholder.""" from ..helpers.i18n import normalize_language from ..helpers.parts import default_search_template return default_search_template(normalize_language(hass), hass.config.country) def _build_full_settings( options: Mapping[str, Any], *, notify_targets: list[str] | None = None, battery_notes: dict[str, Any] | None = None, battery_lifetimes: list[dict[str, Any]] | None = None, part_search_url_default: str = "", ) -> dict[str, Any]: """Build a full settings dict from global entry options. ``notify_targets`` is the shared pickable-notify-target list (see ``helpers/notify_targets.build_notify_targets``) surfaced under ``general.notify_targets`` so the panel picker uses the exact same set as the options flow instead of recomputing it client-side. ``part_search_url_default`` is the computed built-in search template (:func:`_search_url_default`), never stored. """ # Every fallback below is the registry default (settings_registry) via # _opt — tests/test_settings_defaults.py pins the echo to that table. return { "features": { "adaptive": _opt(options, CONF_ADVANCED_ADAPTIVE), "predictions": _opt(options, CONF_ADVANCED_PREDICTIONS), "seasonal": _opt(options, CONF_ADVANCED_SEASONAL), "environmental": _opt(options, CONF_ADVANCED_ENVIRONMENTAL), "budget": _opt(options, CONF_ADVANCED_BUDGET), "groups": _opt(options, CONF_ADVANCED_GROUPS), "checklists": _opt(options, CONF_ADVANCED_CHECKLISTS), "schedule_time": _opt(options, CONF_ADVANCED_SCHEDULE_TIME), "completion_actions": _opt(options, CONF_ADVANCED_COMPLETION_ACTIONS), }, # Top-level (not a feature toggle, not a bool): list of HA user IDs # whose UI gets the full admin panel even though they're not HA admins. "admin_panel_user_ids": _opt(options, CONF_ADMIN_PANEL_USER_IDS), # v2.8.4: master switch gating whether the allowlist actually grants # write. Default False → operator allowlist is read-only. "operator_write_enabled": _opt(options, CONF_OPERATOR_WRITE_ENABLED), # (#67): ordered objects-table columns for the panel All-Objects view. "objects_table_columns": _opt(options, CONF_OBJECTS_TABLE_COLUMNS), # #169 follow-up: per-member avatar overrides (initials / palette colour). "member_display": _opt(options, CONF_MEMBER_DISPLAY), # v2.21: template-gallery curation (ids hidden from the pickers). "disabled_template_ids": _opt(options, CONF_DISABLED_TEMPLATE_IDS), # v2.10.0: archive automation thresholds (panel Settings → Archive). # oneoff_days: auto-archive a completed one-off after N days (0 = off). # delete_archived_oneoff_days: auto-delete an auto-archived one-off N # days after archiving (0 = never; manual archives are never deleted). "archive": { "oneoff_days": _opt(options, CONF_ARCHIVE_ONEOFF_DAYS), "delete_archived_oneoff_days": _opt(options, CONF_DELETE_ARCHIVED_ONEOFF_DAYS), }, "general": { "default_warning_days": _opt(options, CONF_DEFAULT_WARNING_DAYS), # #146: household "low" floors for discovery + the battery fleet. "default_consumable_threshold": _opt(options, CONF_DEFAULT_CONSUMABLE_THRESHOLD), "battery_low_percent": _opt(options, CONF_BATTERY_LOW_PERCENT), # #180: a low battery counts as replaced only above this level. "battery_recovered_percent": _opt(options, CONF_BATTERY_RECOVERED_PERCENT), # #181 follow-up: a level-driven recovery records the replacement # (Battery Notes date + stock) by itself — advanced, off by default. "battery_auto_record_recovery": _opt(options, CONF_BATTERY_AUTO_RECORD_RECOVERY), # D#182: shopping-search template ("" = automatic) + the automatic # value in effect (computed: country, then UI language). "part_search_url_template": _opt(options, CONF_PART_SEARCH_URL_TEMPLATE), "part_search_url_default": part_search_url_default, # D#162 follow-up: the household's per-type lifetime overrides and, # computed by the caller, the effective lifetime catalog Settings # renders (type, months, source: override / learned / table / default). "battery_lifetime_months": dict(_opt(options, CONF_BATTERY_LIFETIME_MONTHS) or {}), "battery_lifetimes": list(battery_lifetimes or []), # Computed, never stored: what Battery Notes currently reports # (default + up to 5 named override devices) — the Settings hint. "battery_notes": battery_notes, "notifications_enabled": _opt(options, CONF_NOTIFICATIONS_ENABLED), "notify_service": _opt(options, CONF_NOTIFY_SERVICE), # v2.67: buy-task shopping sync target ("" = off). "shopping_list_entity": _opt(options, CONF_SHOPPING_LIST_ENTITY), # Shared pickable-target list so the panel picker can't drift from # the options-flow dropdown (both go through build_notify_targets). "notify_targets": notify_targets or [], "panel_enabled": _opt(options, CONF_PANEL_ENABLED), "panel_title": _opt(options, CONF_PANEL_TITLE), # Opt-in copy of the shipped Assist sentences into # /custom_sentences/ (the only place the classic # conversation agent reads them from). "install_assist_sentences": _opt(options, CONF_INSTALL_ASSIST_SENTENCES), # #145: task-row action style + the one-time "new look" notice # (set by the 5→6 migration for existing installs, cleared by the # panel banner). "row_action_style": _opt(options, CONF_ROW_ACTION_STYLE), "row_action_notice_pending": _opt(options, CONF_ROW_ACTION_NOTICE), "ref_numbers_in_lists": bool(_opt(options, CONF_REF_NUMBERS_IN_LISTS)), }, "notifications": { "due_soon_enabled": _opt(options, CONF_NOTIFY_DUE_SOON_ENABLED), "due_soon_interval_hours": _opt(options, CONF_NOTIFY_DUE_SOON_INTERVAL), "overdue_enabled": _opt(options, CONF_NOTIFY_OVERDUE_ENABLED), "overdue_interval_hours": _opt(options, CONF_NOTIFY_OVERDUE_INTERVAL), "triggered_enabled": _opt(options, CONF_NOTIFY_TRIGGERED_ENABLED), "triggered_interval_hours": _opt(options, CONF_NOTIFY_TRIGGERED_INTERVAL), "quiet_hours_enabled": _opt(options, CONF_QUIET_HOURS_ENABLED), "quiet_hours_start": _opt(options, CONF_QUIET_HOURS_START), "quiet_hours_end": _opt(options, CONF_QUIET_HOURS_END), "max_per_day": _opt(options, CONF_MAX_NOTIFICATIONS_PER_DAY), "bundling_enabled": _opt(options, CONF_NOTIFICATION_BUNDLING_ENABLED), "bundle_threshold": _opt(options, CONF_NOTIFICATION_BUNDLE_THRESHOLD), # v1.4.0 (#44): default keeps backwards-compatible per-status titles "title_style": _opt(options, CONF_NOTIFICATION_TITLE_STYLE), # #173 follow-up: completion notifications (off | automatic | all). "completed": _opt(options, CONF_NOTIFY_COMPLETED), # Multiple lead-time reminders (days before due); [] = off. "reminder_lead_days": _opt(options, CONF_REMINDER_LEAD_DAYS), # v2.26: notification routing — saved-view id scoping which # tasks may notify ("" = all tasks). "scope_view_id": _opt(options, CONF_NOTIFY_SCOPE_VIEW_ID), # #165: your own notification rule — event-only delivery and the # extra-data template merged into every notify payload. "event_only": _opt(options, CONF_NOTIFY_EVENT_ONLY), "extra_data": _opt(options, CONF_NOTIFY_EXTRA_DATA), }, "actions": { "complete_enabled": _opt(options, CONF_ACTION_COMPLETE_ENABLED), "skip_enabled": _opt(options, CONF_ACTION_SKIP_ENABLED), "snooze_enabled": _opt(options, CONF_ACTION_SNOOZE_ENABLED), "snooze_duration_hours": _opt(options, CONF_SNOOZE_DURATION_HOURS), "weekly_digest_enabled": _opt(options, CONF_WEEKLY_DIGEST_ENABLED), "warranty_reminder_enabled": _opt(options, CONF_WARRANTY_REMINDER_ENABLED), "warranty_reminder_days": _opt(options, CONF_WARRANTY_REMINDER_DAYS), }, "budget": { "monthly": _opt(options, CONF_BUDGET_MONTHLY), "yearly": _opt(options, CONF_BUDGET_YEARLY), "alerts_enabled": _opt(options, CONF_BUDGET_ALERTS_ENABLED), "alert_threshold_pct": _opt(options, CONF_BUDGET_ALERT_THRESHOLD), **_currency_block(options), }, # Vacation mode (v1.2.0). Mirror the active flag so the panel can # decide whether to show the Vacation tab without a separate WS call. "vacation": _vacation_summary(options), } def _vacation_summary(options: Mapping[str, Any]) -> dict[str, Any]: """Embed-friendly slice of vacation state for the /settings response. Builds the canonical VacationState from the options mapping and serialises via its single wire serializer, so the /settings embed and /vacation/state can never drift (window/active math lives in one place). """ from ..helpers.vacation import VacationState return VacationState.from_options(options).as_wire_dict() @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/settings"}) @websocket_api.async_response async def ws_get_settings( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all global settings.""" from ..helpers.battery_fleet import battery_notes_summary bn = battery_notes_summary(hass) global_entry = _get_global_entry(hass) if global_entry is None: connection.send_result( msg["id"], _build_full_settings( {}, notify_targets=build_notify_targets(hass), battery_notes=bn, battery_lifetimes=_battery_lifetime_catalog(hass), part_search_url_default=_search_url_default(hass), ), ) return options = global_entry.options or global_entry.data connection.send_result( msg["id"], _build_full_settings( options, notify_targets=build_notify_targets(hass, current=options.get(CONF_NOTIFY_SERVICE, "")), battery_notes=bn, battery_lifetimes=_battery_lifetime_catalog(hass), part_search_url_default=_search_url_default(hass), ), ) @websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/statistics"}) @websocket_api.async_response async def ws_get_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return aggregated statistics.""" # Counts come from the shared aggregator (single source of truth) so the # panel/card chips and the global summary sensors can never diverge. counts = compute_status_counts(hass) # (#86) Registry-resolved entity_ids of the four summary sensors. The # dashboard strategy's KPI chips used to hardcode # sensor.maintenance_supporter_, which breaks whenever the actual id # differs (renamed by the user, a _2 collision suffix, or a pre-pinning # install that registered localized ids) — the chips then read "unknown" # forever. unique_ids are stable, so resolve through the registry. from homeassistant.helpers import entity_registry as er ent_reg = er.async_get(hass) summary_entity_ids = { key: ent_reg.async_get_entity_id("sensor", DOMAIN, f"{GLOBAL_UNIQUE_ID}_summary_{key}") for key in ("overdue", "due_soon", "triggered", "ok") } connection.send_result( msg["id"], { "summary_entity_ids": summary_entity_ids, "total_objects": counts["total_objects"], "total_tasks": counts["total_tasks"], "overdue": counts["overdue"], "due_soon": counts["due_soon"], "triggered": counts["triggered"], # (#86, 2nd report) `ok` lets the dashboard chips render the real # count directly when the summary sensors don't exist (e.g. the # global entry was deleted, leaving orphan object entries) instead # of reading a non-existent entity and showing "unknown". "ok": counts["ok"], "total_cost": counts["total_cost"], # Currency display for the cards, which have no budget_status call # of their own: symbol + decimal places, one source for every amount. "budget": _currency_block(get_global_options(hass)), }, ) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/subscribe", # 2.52 delta protocol opt-in — see the handler docstring. vol.Optional("deltas", default=False): bool, # Compact payloads (perf wave 2 #3): same opt-in + hydration contract # as the `objects` read — empty keys stripped from every snapshot and # delta this subscription ships. vol.Optional("compact", default=False): bool, } ) @websocket_api.async_response async def ws_subscribe( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Subscribe to real-time maintenance updates. Two protocols, chosen by the subscriber: * legacy (default): every event carries the FULL ``{objects: [...]}`` payload — exactly the pre-2.52 behaviour, kept for stale-cached frontends that predate the delta merge. * ``deltas: true``: events carry ``{delta: [...], removed: [entry_id...]}`` — only entries whose coordinator fired AND whose rebuilt response actually differs (hash suppression), so the 5-minute timer waves that change nothing send NOTHING, a real change ships ~one object instead of the whole install, and the per-push server build touches one entry instead of all of them. Both protocols share the 1 s coalescing window and an immediate full snapshot on subscribe. """ import json deltas: bool = msg.get("deltas", False) compact: bool = msg.get("compact", False) attached_entry_ids: dict[str, tuple[Any, Callable[[], None]]] = {} unsub_callbacks: list[Callable[[], None]] = [] dirty: set[str] = set() last_hash: dict[str, int] = {} debounce_unsub: Callable[[], None] | None = None def _build(entry: Any) -> dict[str, Any]: rd = _get_runtime_data(hass, entry.entry_id) coord_data = rd.coordinator.data if rd and rd.coordinator else None return _build_object_response(hass, entry, coord_data, compact=compact) def _hash(resp: dict[str, Any]) -> int: return hash(json.dumps(resp, sort_keys=True, default=str)) @callback def _send_now(_now: Any = None) -> None: """Build and push once — full payload or suppressed per-entry delta.""" nonlocal debounce_unsub debounce_unsub = None entries = _get_object_entries(hass) if not deltas: connection.send_message( websocket_api.event_message(msg["id"], {"objects": [_build(e) for e in entries]}) ) return current_ids = {e.entry_id for e in entries} removed = sorted(eid for eid in last_hash if eid not in current_ids) for eid in removed: last_hash.pop(eid, None) changed: list[dict[str, Any]] = [] for entry in entries: known = entry.entry_id in last_hash if known and entry.entry_id not in dirty: continue resp = _build(entry) h = _hash(resp) if not known or last_hash[entry.entry_id] != h: last_hash[entry.entry_id] = h changed.append(resp) dirty.clear() if changed or removed: connection.send_message( websocket_api.event_message(msg["id"], {"delta": changed, "removed": removed}) ) @callback def _send_snapshot() -> None: """The immediate full state a fresh subscriber renders from.""" entries = _get_object_entries(hass) result = [] for entry in entries: resp = _build(entry) if deltas: last_hash[entry.entry_id] = _hash(resp) result.append(resp) connection.send_message(websocket_api.event_message(msg["id"], {"objects": result})) @callback def _forward_update(entry_id: str | None = None) -> None: """Coalesce coordinator updates into one push per second. Every object entry runs its OWN coordinator on the same 5-minute interval, and their timers cluster at boot — measured on a live instance: a ~60-push wave of the FULL payload every 5 minutes, 72.7 MB in 5.5 idle minutes, each push a complete panel re-render (the scroll jank users feel). One trailing send per window carries the same final state; the payload is rebuilt at send time. """ nonlocal debounce_unsub if entry_id is not None: dirty.add(entry_id) if debounce_unsub is None: debounce_unsub = async_call_later(hass, 1.0, _send_now) def _attach_entry(entry_id: str) -> None: """Attach a coordinator listener for a specific entry. Every entry RELOAD (task create/edit/delete, options flow, ...) builds a brand-new coordinator and re-announces the entry via SIGNAL_NEW_OBJECT_ENTRY. An "already attached" early return kept this subscription bound to the dead coordinator, so other clients froze for that object until a page reload (bug audit 2026-08-29). Track the coordinator instance and re-attach when it changed. """ rd = _get_runtime_data(hass, entry_id) coordinator = rd.coordinator if rd else None if coordinator is None: return previous = attached_entry_ids.get(entry_id) if previous is not None: if previous[0] is coordinator: return previous[1]() unsub_callbacks.remove(previous[1]) @callback def _on_update(eid: str = entry_id) -> None: _forward_update(eid) unsub = coordinator.async_add_listener(_on_update) unsub_callbacks.append(unsub) attached_entry_ids[entry_id] = (coordinator, unsub) # Register listeners on all existing coordinators entries = _get_object_entries(hass) for entry in entries: _attach_entry(entry.entry_id) # Listen for new object entries added after subscription @callback def _on_new_entry(entry_id: str) -> None: _attach_entry(entry_id) _forward_update(entry_id) unsub_callbacks.append(async_dispatcher_connect(hass, SIGNAL_NEW_OBJECT_ENTRY, _on_new_entry)) @callback def _unsub() -> None: nonlocal debounce_unsub if debounce_unsub is not None: debounce_unsub() debounce_unsub = None for unsub in unsub_callbacks: unsub() connection.subscriptions[msg["id"]] = _unsub # Send initial data IMMEDIATELY — the debounce is for update storms, # not for the snapshot a fresh subscriber renders from. connection.send_result(msg["id"]) _send_snapshot() @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/schedule/preview", # A DRAFT schedule in Schedule.to_dict form — validated leniently by # Schedule.from_dict (unknown kinds sanitize to manual → empty result). vol.Required("schedule"): dict, vol.Optional("last_performed"): vol.Any(str, None), vol.Optional("times_performed", default=0): vol.All(int, vol.Range(min=0, max=100000)), vol.Optional("count", default=3): vol.All(int, vol.Range(min=1, max=10)), } ) @websocket_api.async_response async def ws_schedule_preview( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Next N occurrences of a draft schedule — computed by the REAL engine. Powers the task dialog's live "next dates" preview (#83 roadmap item). Stateless and read-only: instantiates Schedule.from_dict and iterates next_due(), simulating an ON-TIME completion per step (last_performed / last_planned_due advance, times_performed increments) — so completion- anchored intervals, calendar kinds, season windows, business-day rolls, ±offsets and finite series all advance exactly as the engine would. Never a frontend reimplementation (the #103 drift lesson). """ from datetime import date as date_cls from homeassistant.util import dt as dt_util from ..helpers.schedule import KIND_CALENDAR, Schedule, preview_occurrences lp: date_cls | None = None raw_lp = msg.get("last_performed") if raw_lp: try: lp = date_cls.fromisoformat(raw_lp) except ValueError: connection.send_error(msg["id"], "invalid_date", "Invalid last_performed (expected YYYY-MM-DD)") return today = dt_util.now().date() try: sched = Schedule.from_dict(msg["schedule"]) if sched.kind == KIND_CALENDAR and sched.entity_id: # #187: a calendar picked in the dialog is not in the coordinator # cache yet — fetch its events now so the preview shows real dates. from ..helpers.calendar_source import async_refresh_calendar_occurrences await async_refresh_calendar_occurrences(hass, {sched.entity_id}) dates, series_ended = preview_occurrences( sched, last_performed=lp, times_performed=int(msg.get("times_performed", 0)), today=today, count=int(msg.get("count", 3)), ) except (TypeError, ValueError) as err: connection.send_error(msg["id"], "invalid_input", f"Invalid schedule: {err}") return connection.send_result( msg["id"], {"occurrences": [d.isoformat() for d in dates], "series_ended": series_ended}, ) @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/budget_status"}) @websocket_api.async_response async def ws_get_budget_status( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return current budget status (monthly/yearly spent vs budget).""" from ..helpers.budget import compute_spend global_entry = _get_global_entry(hass) global_options: Mapping[str, Any] = (global_entry.options or global_entry.data) if global_entry else {} monthly_budget = float(global_options.get(CONF_BUDGET_MONTHLY, 0)) yearly_budget = float(global_options.get(CONF_BUDGET_YEARLY, 0)) threshold_pct = int(global_options.get(CONF_BUDGET_ALERT_THRESHOLD, 80)) # Shared with the coordinator's budget cache (which drives the ALERT), so # the number the panel draws and the number that triggers the notification # are the same number by construction. monthly_spent, yearly_spent = compute_spend(hass) currency = _currency_block(global_options) connection.send_result( msg["id"], { "monthly_budget": monthly_budget, "monthly_spent": round(monthly_spent, 2), "yearly_budget": yearly_budget, "yearly_spent": round(yearly_spent, 2), "alert_threshold_pct": threshold_pct, "currency_symbol": currency["currency_symbol"], "currency_decimals": currency["currency_decimals"], }, ) # --------------------------------------------------------------------------- # Global settings update # --------------------------------------------------------------------------- def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, Any], str | None]: """Filter + validate a flat settings dict against the registry. Returns ``(filtered, notify_error)`` — the validated subset plus the error code when ``notify_service`` was present but invalid. Shared by ``global/update`` and the settings-export import path so the two surfaces cannot drift.""" # Filter to allowed keys and validate types filtered: dict[str, Any] = {} for key, expected_type in _ALLOWED_SETTING_KEYS.items(): if key in settings_input: val = settings_input[key] # bool is an int subclass in Python, so a bare isinstance() let # `{"default_warning_days": true}` through as 1 — reject bools for # every numeric spec (bug audit 2026-09-12). if expected_type in (int, float) and isinstance(val, bool): continue # Accept int for float fields if expected_type is float and isinstance(val, int): val = float(val) # The options flow's NumberSelector persists ints as floats (7.0); # a settings export → import used to drop those keys silently. # An INTEGRAL float is the same number — coerce it; 7.5 stays out. if expected_type is int and isinstance(val, float) and math.isfinite(val) and val.is_integer(): val = int(val) if isinstance(val, expected_type): filtered[key] = val # Range-validate numeric and string settings against the shared registry. for key, (lo, hi) in INT_RANGES.items(): if key in filtered and not (lo <= filtered[key] <= hi): del filtered[key] for key, (flo, fhi) in FLOAT_RANGES.items(): if key in filtered: v = filtered[key] if not math.isfinite(v) or not (flo <= v <= fhi): del filtered[key] for key, max_len in STR_MAX_LENGTHS.items(): if key in filtered and len(filtered[key]) > max_len: del filtered[key] # Sidebar panel title (#63): trim + cap rather than drop, so an over-long # or padded value is normalised instead of silently ignored. A blank value # is kept (it clears the override → panel falls back to the default title). if CONF_PANEL_TITLE in filtered: raw_title = filtered[CONF_PANEL_TITLE] if isinstance(raw_title, str): filtered[CONF_PANEL_TITLE] = raw_title.strip()[:MAX_PANEL_TITLE_LENGTH] else: del filtered[CONF_PANEL_TITLE] # v1.4.0 (#44): enum-validate notification_title_style. Anything outside # the known set is dropped silently so a bogus value can't get into the # ConfigEntry options. from ..const import NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES if CONF_NOTIFY_COMPLETED in filtered and filtered[CONF_NOTIFY_COMPLETED] not in NOTIFY_COMPLETED_MODES: filtered[CONF_NOTIFY_COMPLETED] = "off" if CONF_NOTIFICATION_TITLE_STYLE in filtered and filtered[CONF_NOTIFICATION_TITLE_STYLE] not in NOTIFICATION_TITLE_STYLES: del filtered[CONF_NOTIFICATION_TITLE_STYLE] # #145: same treatment for the row-action style. from ..const import ROW_ACTION_STYLES if CONF_ROW_ACTION_STYLE in filtered and filtered[CONF_ROW_ACTION_STYLE] not in ROW_ACTION_STYLES: del filtered[CONF_ROW_ACTION_STYLE] # v1.4.6 (#44 follow-up): drop quiet-hours time strings that aren't valid # HH:MM[:SS]. The HA TimeSelector in the options-flow rejects empty / bad # strings as "Invalid time" and that error blocks the entire form save — # even when the user is here to change something else and quiet_hours is # disabled. By dropping invalid values here, the form falls back to the # 22:00 / 08:00 defaults next render. for time_key in (CONF_QUIET_HOURS_START, CONF_QUIET_HOURS_END): if time_key in filtered: v = filtered[time_key] if not isinstance(v, str) or not TIME_HHMMSS_PATTERN.match(v): del filtered[time_key] # Sanitise admin_panel_user_ids: drop non-string entries + whitespace-only # entries, cap each at 64 chars (HA user UUIDs are 32), cap list at 50 # entries, dedupe. if CONF_ADMIN_PANEL_USER_IDS in filtered: raw = filtered[CONF_ADMIN_PANEL_USER_IDS] cleaned: list[str] = [] seen: set[str] = set() for v in raw: if not isinstance(v, str): continue stripped = v.strip() if not stripped or len(stripped) > 64: continue if stripped in seen: continue seen.add(stripped) cleaned.append(stripped) if len(cleaned) >= 50: break filtered[CONF_ADMIN_PANEL_USER_IDS] = cleaned # Multiple lead-time reminders: keep only ints within 0..365, dedupe, sort # descending (furthest lead first), cap the list. Empty list is valid — it # turns the feature off. if CONF_REMINDER_LEAD_DAYS in filtered: raw_leads = filtered[CONF_REMINDER_LEAD_DAYS] leads: list[int] = [] for v in raw_leads: if isinstance(v, bool) or not isinstance(v, int): continue if 0 <= v <= 365 and v not in leads: leads.append(v) filtered[CONF_REMINDER_LEAD_DAYS] = sorted(leads, reverse=True)[:MAX_REMINDER_LEADS] # (#67): objects_table_columns — keep only known column keys, preserve the # caller's order, dedupe. An empty/invalid result falls back to the default # set (the panel also defaults defensively). if CONF_OBJECTS_TABLE_COLUMNS in filtered: raw_cols = filtered[CONF_OBJECTS_TABLE_COLUMNS] cols: list[str] = [] seen_cols: set[str] = set() for v in raw_cols: if not isinstance(v, str) or v not in KNOWN_OBJECT_TABLE_COLUMNS: continue if v in seen_cols: continue seen_cols.add(v) cols.append(v) filtered[CONF_OBJECTS_TABLE_COLUMNS] = cols or list(DEFAULT_OBJECTS_TABLE_COLUMNS) # #169 follow-up: member avatars — initials capped, colours from the # palette only, empty entries dropped (an empty map clears every override). if CONF_MEMBER_DISPLAY in filtered: from ..helpers.member_display import sanitize_member_display filtered[CONF_MEMBER_DISPLAY] = sanitize_member_display(filtered[CONF_MEMBER_DISPLAY]) # D#162 follow-up: battery lifetime overrides — canonical type keys, months # within range, junk dropped (an empty map clears every override). if CONF_BATTERY_LIFETIME_MONTHS in filtered: from ..helpers.battery_lifetime import sanitize_lifetime_overrides filtered[CONF_BATTERY_LIFETIME_MONTHS] = sanitize_lifetime_overrides(filtered[CONF_BATTERY_LIFETIME_MONTHS]) # v2.21: disabled_template_ids — keep only ids of templates that actually # exist (a typo/stale id must not linger invisibly), dedupe. if CONF_DISABLED_TEMPLATE_IDS in filtered: from ..templates import KNOWN_TEMPLATE_IDS raw_tids = filtered[CONF_DISABLED_TEMPLATE_IDS] tids: list[str] = [] for v in raw_tids: if isinstance(v, str) and v in KNOWN_TEMPLATE_IDS and v not in tids: tids.append(v) filtered[CONF_DISABLED_TEMPLATE_IDS] = tids # Validate shopping_list_entity if provided: "" clears; otherwise it must # be a todo.* entity id. Format-only — NO existence check (the list's # integration may load later; the sync self-heals on first appearance). if CONF_SHOPPING_LIST_ENTITY in filtered: raw_ent = (filtered[CONF_SHOPPING_LIST_ENTITY] or "").strip() if raw_ent and not valid_entity_id(raw_ent): return filtered, "invalid_shopping_list_entity" if raw_ent and not raw_ent.startswith("todo."): return filtered, "invalid_shopping_list_entity" filtered[CONF_SHOPPING_LIST_ENTITY] = raw_ent # D#182: the shopping-search template — "" clears (automatic by country / # language); anything else must be an http(s) URL WITH the {q} placeholder, # or the search would open the same page for every part. if CONF_PART_SEARCH_URL_TEMPLATE in filtered: from ..helpers.parts import valid_search_template raw_tpl = (filtered[CONF_PART_SEARCH_URL_TEMPLATE] or "").strip() if raw_tpl and not valid_search_template(raw_tpl): return filtered, "invalid_search_template" filtered[CONF_PART_SEARCH_URL_TEMPLATE] = raw_tpl # Validate notify_service if provided if CONF_NOTIFY_SERVICE in filtered: from ..config_flow_options_global import validate_notify_service normalized, error = validate_notify_service(filtered[CONF_NOTIFY_SERVICE]) if error: return filtered, error filtered[CONF_NOTIFY_SERVICE] = normalized return filtered, None @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/global/update", vol.Required("settings"): dict, } ) @websocket_api.require_admin @websocket_api.async_response async def ws_update_global_settings( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Update global settings. Accepts a flat dict of setting keys to update. Unknown keys are silently ignored. Returns the full updated settings. """ ctx = _load_global_options(hass, connection, msg) if ctx is None: return global_entry, current_options = ctx settings_input: dict[str, Any] = msg["settings"] filtered, notify_error = sanitize_settings_input(settings_input) if notify_error: connection.send_error(msg["id"], notify_error, f"Invalid setting value: {notify_error}") return if not filtered: connection.send_error(msg["id"], "invalid_input", "No valid setting keys provided") return # Merge with existing options merged = current_options merged.update(filtered) _save_global_options(hass, global_entry, merged) _LOGGER.debug("Global settings updated via WS: %s", list(filtered.keys())) connection.send_result( msg["id"], _build_full_settings( merged, notify_targets=build_notify_targets(hass, current=merged.get(CONF_NOTIFY_SERVICE, "")), battery_lifetimes=_battery_lifetime_catalog(hass), part_search_url_default=_search_url_default(hass), ), ) # --------------------------------------------------------------------------- # Test notification # --------------------------------------------------------------------------- @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/global/test_notification", vol.Optional("user_id"): vol.Any(ID_FIELD, None), } ) @websocket_api.require_admin @websocket_api.async_response async def ws_test_notification( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Send a test notification to the household service or to ONE member. With ``user_id`` the send goes through the same per-user resolution the real reminders use, so a green result actually proves that member's phone is reachable. """ from ..config_flow_options_global import ( _get_test_result_text, send_test_notification, ) ctx = _load_global_options(hass, connection, msg) if ctx is None: return _global_entry, options = ctx result_key = await send_test_notification(hass, options, user_id=msg.get("user_id")) connection.send_result( msg["id"], { "success": result_key == "success", "result": result_key, "message": _get_test_result_text(hass, result_key), }, ) @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/notify/user_targets"}) @websocket_api.require_admin @websocket_api.async_response async def ws_notify_user_targets( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Report which notify services each household member resolves to. Answers the question the settings page could not previously answer: "will Bob actually get his reminders?". Resolution runs through the very same helper the reminder path uses, so what is shown is what will be used — a separate lookup here would be able to disagree with reality, which is the failure mode that made the wrong-service bug behind #75 invisible. Admin-only: the resolved service names carry members' device names. """ from ..helpers.notification_manager import get_user_notify_services targets: list[dict[str, Any]] = [] for user in await hass.auth.async_get_users(): if not user.is_active or user.system_generated: continue targets.append( { "user_id": user.id, "name": user.name, "services": await get_user_notify_services(hass, user.id), } ) connection.send_result(msg["id"], {"targets": targets})