186 files

This commit is contained in:
Home Assistant Version Control
2026-09-14 12:56:59 +00:00
parent 6be1e92941
commit bd8280defe
186 changed files with 14904 additions and 1087 deletions
@@ -266,6 +266,8 @@ def _build_task_summary(
# dialog prefill what was already done.
"checklist_progress": task_data.get("checklist_progress", {}),
"labels": task_data.get("labels", []),
# D#183: external to-do lists the task is mirrored into while due.
"mirror_todo_entities": task_data.get("mirror_todo_entities", []),
# Payload diet (perf, 2026-08): the LIST response carries only the
# most recent entries — at 150+ tasks the full histories dominated
# the `objects` payload (407 KB measured at just 8 entries/task) and
@@ -395,7 +397,7 @@ def _build_object_response(
**part,
"stock": stock,
"is_low": part_is_low(part, stock),
"shopping_url": resolve_shopping_url(part, search_template, lang),
"shopping_url": resolve_shopping_url(part, search_template, lang, hass.config.country),
}
)
@@ -692,6 +694,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_battery_fleet_history,
ws_battery_fleet_mark_replaced,
ws_battery_fleet_overview,
ws_battery_fleet_record_replacement,
ws_battery_fleet_set_due_without_sensor,
ws_battery_fleet_set_excluded,
ws_battery_fleet_set_included,
@@ -856,6 +859,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_status)
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
websocket_api.async_register_command(hass, ws_battery_fleet_record_replacement)
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
websocket_api.async_register_command(hass, ws_battery_fleet_set_included)
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
@@ -12,6 +12,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import (
@@ -26,6 +27,7 @@ from ..helpers.battery_fleet import (
)
from ..helpers.battery_fleet_setup import (
async_mark_replaced,
async_record_replacement,
async_setup_battery_fleet,
find_fleet_entry,
fleet_task_trigger_ok,
@@ -234,3 +236,35 @@ async def ws_battery_fleet_mark_replaced(
"""Mark batteries replaced (press their button + consume the type-parts)."""
result = await async_mark_replaced(hass, msg.get("entity_ids"))
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/record_replacement",
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
# ISO datetime of the detected swap (the roster's calendar-sync chip).
vol.Required("replaced_at"): vol.All(str, vol.Length(max=40)),
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_record_replacement(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Record a detected-but-unrecorded swap in Battery Notes AND consume the
type-part cells like the Replaced action (#181). Returns ``{recorded,
already_recorded, consumed: {part_id: qty}}``."""
try:
result = await async_record_replacement(hass, msg["entity_id"], msg["replaced_at"])
except HomeAssistantError as err:
code = str(err)
if code == "not_found":
connection.send_error(msg["id"], "not_found", "Battery not found in the fleet")
elif code == "invalid_device":
connection.send_error(msg["id"], "invalid_device", "Battery has no device to record on")
elif code == "invalid_date":
connection.send_error(msg["id"], "invalid_date", "replaced_at is not an ISO datetime")
else:
connection.send_error(msg["id"], "not_available", "Battery Notes is not available")
return
connection.send_result(msg["id"], result)
@@ -31,6 +31,7 @@ from ..const import (
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BATTERY_LIFETIME_MONTHS,
CONF_BATTERY_LOW_PERCENT,
CONF_BATTERY_RECOVERED_PERCENT,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
@@ -63,6 +64,7 @@ from ..const import (
CONF_OPERATOR_WRITE_ENABLED,
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_PART_SEARCH_URL_TEMPLATE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
@@ -154,12 +156,22 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
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.
@@ -167,6 +179,8 @@ def _build_full_settings(
``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.
@@ -207,6 +221,12 @@ def _build_full_settings(
# #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),
# 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).
@@ -311,7 +331,13 @@ async def ws_get_settings(
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)),
_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
@@ -323,6 +349,7 @@ async def ws_get_settings(
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),
),
)
@@ -821,6 +848,17 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
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
@@ -861,7 +899,7 @@ async def ws_update_global_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}")
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")
@@ -880,6 +918,7 @@ async def ws_update_global_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),
),
)
@@ -800,6 +800,8 @@ async def ws_import_json(
# backup round-trips them (validated/clamped just below).
"priority",
"labels",
# D#183: mirror targets (shape-sanitized below).
"mirror_todo_entities",
"earliest_completion_days",
"ref_no",
"on_complete_action",
@@ -960,6 +962,17 @@ async def ws_import_json(
else:
task_data.pop("readings", None)
# D#183: mirror targets — todo.* ids only, deduped, capped; an
# empty result drops the key (same rules as the WS write paths).
if task_data.get("mirror_todo_entities") is not None:
from ..helpers.sanitize import sanitize_mirror_todo_entities
mirrors = sanitize_mirror_todo_entities(task_data["mirror_todo_entities"])
if mirrors:
task_data["mirror_todo_entities"] = mirrors
else:
task_data.pop("mirror_todo_entities", None)
# schedule_time: canonical HH:MM. The options flow's TimeSelector
# stores "HH:MM:SS" and the export writes it verbatim — that used
# to be DROPPED here (strict HH:MM), so a backup lost the time.
@@ -32,12 +32,14 @@ from ..const import (
MAX_LABEL_LENGTH,
MAX_LABELS,
MAX_META_LENGTH,
MAX_MIRROR_TODO_LISTS,
MAX_NAME_LENGTH,
MAX_NFC_TAG_LENGTH,
MAX_READING_UNIT_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
MAX_URL_LENGTH,
MIRROR_TODO_ENTITY_PATTERN,
NOTIFICATION_MANAGER_KEY,
HistoryEntryType,
)
@@ -71,6 +73,7 @@ from .tasks_persist import async_persist_task
from .tasks_validation import (
_check_nfc_tag_duplicate,
_is_safe_url,
_mirror_todo_error,
_validate_trigger_config,
)
@@ -151,6 +154,8 @@ TASK_UPDATE_FIELD_MAP = {
"priority": "priority",
"checklist": "checklist",
"labels": "labels",
# D#183: external to-do lists the task is mirrored into while due.
"mirror_todo_entities": "mirror_todo_entities",
"schedule_time": "schedule_time",
# v1.3.0
"on_complete_action": "on_complete_action",
@@ -260,6 +265,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Optional("labels"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
),
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
vol.Optional("mirror_todo_entities"): vol.Any(
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
),
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
vol.Optional("schedule_time"): vol.Any(
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
@@ -443,6 +452,18 @@ async def ws_create_task(
from ..helpers.sanitize import sanitize_labels
task_data["labels"] = sanitize_labels(msg["labels"])
# D#183: mirror targets — shape-sanitized, and our own to-do platform is
# refused (it cannot take rows, and mirroring into ourselves is circular).
if msg.get("mirror_todo_entities"):
from ..helpers.sanitize import sanitize_mirror_todo_entities
mirrors = sanitize_mirror_todo_entities(msg["mirror_todo_entities"])
mirror_err = _mirror_todo_error(hass, mirrors)
if mirror_err:
connection.send_error(msg["id"], "invalid_mirror_todo", mirror_err)
return
if mirrors:
task_data["mirror_todo_entities"] = mirrors
if msg.get("schedule_time"):
task_data["schedule_time"] = msg["schedule_time"]
# v1.3.0: optional completion-action + quick-defaults. Strict shape
@@ -545,6 +566,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Optional("labels"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
),
# D#183: see create schema.
vol.Optional("mirror_todo_entities"): vol.Any(
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
),
vol.Optional("schedule_time"): vol.Any(
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
None,
@@ -620,6 +645,17 @@ async def ws_update_task(
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
return
# D#183: mirror targets — same sanitize + own-platform refusal as create;
# an empty list clears the field (the raw-field-map lesson).
if "mirror_todo_entities" in msg:
from ..helpers.sanitize import sanitize_mirror_todo_entities
msg["mirror_todo_entities"] = sanitize_mirror_todo_entities(msg["mirror_todo_entities"])
mirror_err = _mirror_todo_error(hass, msg["mirror_todo_entities"])
if mirror_err:
connection.send_error(msg["id"], "invalid_mirror_todo", mirror_err)
return
for msg_key, data_key in TASK_UPDATE_FIELD_MAP.items():
if msg_key in msg:
task[data_key] = msg[msg_key]
@@ -653,6 +689,9 @@ async def ws_update_task(
task["notify_enabled"] = False
else:
task.pop("notify_enabled", None)
# D#183: an empty mirror list means "off" — drop the key, don't store [].
if "mirror_todo_entities" in msg and not msg["mirror_todo_entities"]:
task.pop("mirror_todo_entities", None)
# The loop above copies values verbatim, which is wrong for part links:
# `task/create` validates them and `task/update` did not, so an edit could
@@ -857,9 +896,12 @@ async def async_delete_task(
hass.config_entries.async_update_entry(entry, data=new_data)
# Clean up Store
# Clean up Store (a mirrored task takes its to-do rows with it, D#183)
store = get_store(hass, entry.entry_id)
if store is not None:
mirror = hass.data.get(DOMAIN, {}).get("todo_mirror")
if mirror is not None and hasattr(mirror, "async_forget_task"):
await mirror.async_forget_task(store, task_id)
store.remove_task(task_id)
await store.async_save()
@@ -79,6 +79,32 @@ def _check_nfc_tag_duplicate(hass: HomeAssistant, nfc_tag_id: str, exclude_task_
return None
# ---------------------------------------------------------------------------
# To-do mirror targets (D#183)
# ---------------------------------------------------------------------------
def _mirror_todo_error(hass: HomeAssistant, entity_ids: list[str]) -> str | None:
"""Refuse Maintenance Supporter's OWN to-do lists as mirror targets.
They only support updating rows (no create/delete), so ``todo.add_item``
would fail on every refresh — and a task mirrored into the list that
already shows it is circular. Returns the error text, or None when every
id is acceptable (unknown / not-yet-loaded lists are fine: the mirror
self-heals when they appear).
"""
from homeassistant.helpers import entity_registry as er
from ..const import DOMAIN
registry = er.async_get(hass)
for eid in entity_ids:
reg_entry = registry.async_get(eid)
if reg_entry is not None and reg_entry.platform == DOMAIN:
return f"{eid} is a Maintenance Supporter list — a task cannot be mirrored into its own integration's list"
return None
# ---------------------------------------------------------------------------
# Trigger config validation
# ---------------------------------------------------------------------------