217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -582,7 +582,7 @@ def _notif_t(key: str, lang: str, **kwargs: str) -> str:
return text
async def _get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
async def get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
"""Find all notify services for a user via mobile_app config entries.
Discovery strategy:
@@ -940,7 +940,7 @@ class NotificationManager:
# Determine target services: user-specific or global
target_services = []
if responsible_user_id:
user_services = await _get_user_notify_services(self.hass, responsible_user_id)
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
_LOGGER.debug(
@@ -1271,7 +1271,7 @@ class NotificationManager:
target_services: list[str] = []
if responsible_user_id:
user_services = await _get_user_notify_services(self.hass, responsible_user_id)
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
if not target_services and self.notify_service:
@@ -22,7 +22,7 @@ is passed in here as a plain ``{part_id: stock}`` map.
from __future__ import annotations
import re
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from datetime import date
from typing import Any
from urllib.parse import quote_plus
@@ -218,21 +218,46 @@ def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
return part
def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) -> list[dict[str, Any]]:
"""Cap/clean a task's ``consumes_parts`` list ([{part_id, quantity}]).
def sanitize_consumes_parts(
raw: Any,
valid_part_ids: set[str] | None = None,
*,
foreign_part_ids: Callable[[str], set[str] | None] | None = None,
) -> list[dict[str, Any]]:
"""Cap/clean a task's ``consumes_parts`` list.
Unknown part ids are dropped when ``valid_part_ids`` is given; quantity is
clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins).
Shape is ``[{part_id, quantity, entry_id?}]``. A link WITHOUT ``entry_id``
consumes a part of the task's own object, which is every link written
before 2.45 and stays the default. With ``entry_id`` it consumes a pool
owned by another object (#111) — several appliances drawing on one box of
filters — and ``foreign_part_ids`` is asked whether that entry really has
that part. Without the callback, foreign links are dropped rather than
trusted.
Unknown ids are dropped when the corresponding validator is given; quantity
is clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins).
The dedupe key is the (entry_id, part_id) PAIR, not the id alone: part ids
are uuid4 in general but the battery fleet mints deterministic ones
(``batt_aa``), so two objects genuinely can carry the same id.
"""
if not isinstance(raw, list):
return []
out: dict[str, dict[str, Any]] = {}
out: dict[tuple[str | None, str], dict[str, Any]] = {}
for item in raw[:MAX_CONSUMES_PER_TASK]:
if not isinstance(item, Mapping):
continue
part_id = str(item.get("part_id") or "").strip()
if not part_id or (valid_part_ids is not None and part_id not in valid_part_ids):
if not part_id:
continue
entry_id = str(item.get("entry_id") or "").strip() or None
if entry_id is None:
if valid_part_ids is not None and part_id not in valid_part_ids:
continue
else:
known = foreign_part_ids(entry_id) if foreign_part_ids else None
if known is None or part_id not in known:
continue
try:
qty = float(item.get("quantity", 1))
except (TypeError, ValueError):
@@ -241,7 +266,15 @@ def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) ->
# invalid input (falls back to 1), matching the old integer clamp.
if qty <= 0:
qty = 1.0
out[part_id] = {"part_id": part_id, "quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY))}
link: dict[str, Any] = {
"part_id": part_id,
"quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY)),
}
# Only written when the pool lives elsewhere, so a same-object link is
# byte-identical to what every earlier version wrote.
if entry_id is not None:
link["entry_id"] = entry_id
out[(entry_id, part_id)] = link
return list(out.values())
@@ -12,6 +12,11 @@ paused) and ``paused_until`` (optional ISO date; the coordinator auto-resumes
on the first refresh on/after that day). The resume core is shared between
the ``object/resume`` WS command and the coordinator's auto-resume so the two
paths cannot drift.
Because resume and unarchive make the same "fresh cycle" promise, the
per-task re-anchor itself lives here too (``reanchor_recurring_task``) and is
imported by the object- and task-unarchive handlers — it used to be three
hand-copied blocks that had each forgotten ``due_override``.
"""
from __future__ import annotations
@@ -42,6 +47,54 @@ def pause_due_for_auto_resume(obj: dict[str, Any], today: date) -> bool:
return True
def clear_cycle_modifiers(task_state: dict[str, Any]) -> None:
"""Drop the previous cycle's per-occurrence modifiers from a task dict.
``last_planned_due`` is the drift-free anchor the next occurrence is
computed from; ``due_override`` is a one-shot postpone that
``Schedule.next_due`` gives precedence over the cadence whenever it is
later than ``last_performed``. Both describe the cycle being abandoned, so
both must go when a task is re-anchored.
"""
task_state.pop("last_planned_due", None)
task_state.pop("due_override", None)
def reanchor_recurring_task(
task_id: str,
*,
store: MaintenanceStore | None,
today_iso: str,
task_data: dict[str, Any] | None = None,
) -> None:
"""Re-anchor ONE recurring task to a fresh cycle starting today.
The single implementation of the "fresh cycle" promise behind object
unarchive, task unarchive and pause/resume. All three previously inlined
``last_performed`` + ``last_planned_due`` and all three forgot
``due_override`` — so a recurring task postponed to a FUTURE date and then
archived/paused came back on that stale postponed date instead of
today + interval, which is precisely what a fresh cycle promises not to do.
Dynamic state lives in the Store when the entry has one; *task_data* is the
static ConfigEntry dict. Pass both where available: the modifiers are
cleared from BOTH because ``MaintenanceStore.merge_task_data`` lets a
static value win when the Store has none, and an IMPORT (websocket/io.py)
can still write ``due_override`` into static entry data after migration has
already run. ``last_performed`` is only written to *task_data* in the
legacy (no-Store) shape.
Callers own the "is this task recurring?" test and the Store save.
"""
if task_data is not None:
clear_cycle_modifiers(task_data)
if store is None:
task_data["last_performed"] = today_iso
if store is not None:
store.set_last_performed(task_id, today_iso)
clear_cycle_modifiers(store._ensure_task(task_id))
def build_resumed_entry_data(
entry_data: dict[str, Any],
store: MaintenanceStore | None,
@@ -67,13 +120,7 @@ def build_resumed_entry_data(
for tid, td in dict(new_data.get(CONF_TASKS, {})).items():
td = dict(td)
if td.get("archived_at") is None and is_recurring(td):
if store is not None:
store.set_last_performed(tid, today_iso)
state = store._ensure_task(tid)
state.pop("last_planned_due", None)
else:
td["last_performed"] = today_iso
td.pop("last_planned_due", None)
reanchor_recurring_task(tid, store=store, today_iso=today_iso, task_data=td)
new_tasks[tid] = td
new_data[CONF_TASKS] = new_tasks
return new_data
@@ -1,11 +1,20 @@
"""Defensive sanitization for config-flow input.
"""Defensive sanitization for the non-WebSocket write paths.
The WebSocket schemas enforce length and range caps on every str/int field at
the boundary. Config-flow forms accept arbitrary lengths because HA's selectors
don't enforce them. To keep both paths at parity (and prevent a malicious or
buggy programmatic config-flow caller from bloating ConfigEntry.data), every
config-flow save handler runs the relevant cap helper below right before
persisting.
the boundary, so the WS handlers need no sanitiser (they reject rather than
truncate). The other two write paths have no such guarantee and DO call the cap
helpers below right before persisting:
* **Config flow** — HA's selectors don't enforce lengths, so a malicious or
buggy programmatic flow caller could otherwise bloat ConfigEntry.data. Every
save handler caps.
* **Services** (``add_task`` / ``update_task``) — their voluptuous schemas
mirror the WS caps, but ``websocket/tasks_persist.py``'s
``async_create_task_simple`` / ``async_update_task_simple`` are plain Python
reachable in-process without going through a schema at all, so they cap too.
Keeping all three at parity is the point: a value one surface rejects must not
be writable through another.
"""
from __future__ import annotations
@@ -147,6 +156,13 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
if rs not in ROTATION_STRATEGIES:
task_data.pop("rotation_strategy", None)
if task_data.get("required_completion_fields") is not None:
from .completion_requirements import sanitize_required_completion_fields
task_data["required_completion_fields"] = sanitize_required_completion_fields(
task_data["required_completion_fields"]
)
seed_rotation_assignee(task_data)
# v1.3.0: per-task on_complete_action — embedded HA service-call config.
@@ -42,6 +42,7 @@ from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
@@ -135,6 +136,7 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_ACTION_SNOOZE_ENABLED, bool),
SettingSpec(CONF_SNOOZE_DURATION_HOURS, int, int_range=(1, 168)),
SettingSpec(CONF_WEEKLY_DIGEST_ENABLED, bool),
SettingSpec(CONF_INSTALL_ASSIST_SENTENCES, bool),
SettingSpec(CONF_WARRANTY_REMINDER_ENABLED, bool),
SettingSpec(CONF_WARRANTY_REMINDER_DAYS, int, int_range=(1, 365)),
# List of days-before-due (bespoke int-list sanitiser in the WS handler).
@@ -22,6 +22,9 @@ from ..const import (
ROTATION_STRATEGIES,
TaskPriority,
)
from .completion_requirements import (
REQUIRABLE_COMPLETION_FIELDS as _REQUIRABLE_COMPLETION_FIELDS,
)
# ─── Enum option sets ────────────────────────────────────────────────────────
@@ -37,6 +40,10 @@ INTERVAL_ANCHORS: tuple[str, ...] = ("completion", "planned")
# task-field consumers import from one module).
ROTATION_STRATEGY_VALUES: tuple[str, ...] = ROTATION_STRATEGIES
# Details a task can demand on completion (re-exported so every task-field
# consumer imports its enums from this one module).
REQUIRABLE_COMPLETION_FIELDS: tuple[str, ...] = _REQUIRABLE_COMPLETION_FIELDS
# ─── Numeric bounds (inclusive) ─────────────────────────────────────────────
WARNING_DAYS_RANGE: tuple[int, int] = (0, 365)