Files
HomeAssistantVS/custom_components/maintenance_supporter/websocket/dashboard.py
T

964 lines
40 KiB
Python

"""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_LIFETIME_MONTHS,
CONF_BATTERY_LOW_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_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 _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,
) -> 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.
"""
# 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),
# 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
# <config>/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)),
)
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),
),
)
@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_<key>, 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: [<object response>...],
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 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"])
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
# 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 notify service: {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),
),
)
# ---------------------------------------------------------------------------
# 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})