147 files

This commit is contained in:
Home Assistant Version Control
2026-08-27 20:54:53 +00:00
parent 52cbcb4187
commit 69f4dc3f17
149 changed files with 13415 additions and 1156 deletions
@@ -60,6 +60,16 @@ def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
_HISTORY_WINDOW = int(os.environ.get("MS_HISTORY_WINDOW", "20"))
def _current_phase_summary(task_data: dict[str, Any]) -> dict[str, Any] | None:
"""{id, name, index, count} of the phase currently due, or None (#139)."""
from ..helpers.phases import current_phase
phase = current_phase(task_data)
if phase is None:
return None
return {"id": phase["id"], "name": phase["name"], "index": phase["index"], "count": phase["count"]}
def _build_task_summary(
hass: HomeAssistant,
task_id: str,
@@ -151,6 +161,13 @@ def _build_task_summary(
# auto-created "buy" reminder, the owning part marker ({part_id}).
"consumes_parts": task_data.get("consumes_parts"),
"part_ref": task_data.get("part_ref"),
# Task phases (#139): defs + cycle + Store-merged cursor, plus the
# resolved current phase so every surface can label "what's due now"
# without re-deriving the clamp.
"phases": task_data.get("phases"),
"phase_sequence": task_data.get("phase_sequence"),
"phase_cursor": task_data.get("phase_cursor", 0),
"current_phase": _current_phase_summary(task_data),
"priority": task_data.get("priority") or DEFAULT_TASK_PRIORITY,
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
# `archived` is the convenience bool the frontend filters on; reason is
@@ -623,6 +640,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_postpone_task,
ws_quick_complete_task,
ws_reset_task,
ws_set_task_phase,
ws_skip_task,
ws_snooze_task,
ws_task_history,
@@ -665,6 +683,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_checklist_progress)
websocket_api.async_register_command(hass, ws_skip_task)
websocket_api.async_register_command(hass, ws_reset_task)
websocket_api.async_register_command(hass, ws_set_task_phase)
websocket_api.async_register_command(hass, ws_snooze_task)
websocket_api.async_register_command(hass, ws_postpone_task)
websocket_api.async_register_command(hass, ws_create_part)
@@ -27,6 +27,7 @@ from ..const import (
MAX_JSON_IMPORT_PAYLOAD_BYTES,
)
from ..helpers.global_options import get_default_warning_days
from ..helpers.phases import clamp_phase_cursor, sanitize_phase_defs, sanitize_phase_sequence
from ..helpers.qr_generator import (
_ACTION_ICON_MAP,
build_qr_url,
@@ -728,6 +729,42 @@ async def ws_import_json(
elif ref is not None:
task_data.pop("part_ref", None)
# Task phases (#139): sanitize like the live WS write, remap each
# phase's part links to the regenerated ids (same rules as the
# task-level links above), and clamp the cursor to the imported
# sequence. The cursor rides entry.data until the fresh entry's
# first setup migrates it into the Store (dynamic field), so a
# restore resumes mid-cycle.
raw_defs = task_entry.get("phases")
raw_seq = task_entry.get("phase_sequence")
if isinstance(raw_defs, dict) and isinstance(raw_seq, list):
defs = sanitize_phase_defs(raw_defs)
for pdef in defs.values():
plinks = pdef.get("consumes_parts")
if not isinstance(plinks, list):
continue
kept = []
for link in plinks:
if not isinstance(link, dict):
continue
foreign = str(link.get("entry_id") or "").strip()
if foreign:
if hass.config_entries.async_get_entry(foreign) is not None:
kept.append(dict(link))
elif link.get("part_id") in part_id_map:
kept.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if kept:
pdef["consumes_parts"] = kept
else:
pdef.pop("consumes_parts", None)
seq = sanitize_phase_sequence(raw_seq, defs)
if defs and seq:
task_data["phases"] = defs
task_data["phase_sequence"] = seq
task_data["phase_cursor"] = clamp_phase_cursor(task_entry.get("phase_cursor"), len(seq))
# Sanitize critical fields from import data
iv = task_data.get("interval_days")
if iv is not None and (not isinstance(iv, int) or iv < 1):
@@ -13,6 +13,7 @@ from .tasks_actions import (
ws_postpone_task,
ws_quick_complete_task,
ws_reset_task,
ws_set_task_phase,
ws_skip_task,
ws_snooze_task,
)
@@ -69,6 +70,7 @@ __all__ = [
"ws_postpone_task",
"ws_quick_complete_task",
"ws_reset_task",
"ws_set_task_phase",
"ws_skip_task",
"ws_snooze_task",
"ws_task_history",
@@ -348,6 +348,47 @@ async def ws_reset_task(
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/set_phase",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# Index into phase_sequence — which cycle step is due NEXT.
vol.Required("cursor"): vol.All(int, vol.Range(min=0, max=100)),
}
)
@websocket_api.async_response
async def ws_set_task_phase(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set which phase of a cyclic task is due next (#139).
The explicit correction path ("I'm actually at step 3") — completions
rotate the cursor themselves and there is deliberately no phase picker
in the complete dialog.
"""
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, entry = ctx
task = (entry.data.get(CONF_TASKS) or {}).get(msg["task_id"]) or {}
sequence = task.get("phase_sequence") or []
if not (task.get("phases") and sequence):
connection.send_error(msg["id"], "no_phases", "Task has no phase cycle")
return
if msg["cursor"] >= len(sequence):
connection.send_error(
msg["id"], "invalid_cursor", f"cursor must be < {len(sequence)}"
)
return
rd.store.set_phase_cursor(msg["task_id"], msg["cursor"])
rd.store.async_delay_save()
await rd.coordinator.async_refresh_now()
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/postpone",
@@ -105,6 +105,8 @@ TASK_UPDATE_FIELD_MAP = {
"nfc_tag_id": "nfc_tag_id",
"reading_unit": "reading_unit",
"consumes_parts": "consumes_parts",
"phases": "phases",
"phase_sequence": "phase_sequence",
"priority": "priority",
"checklist": "checklist",
"labels": "labels",
@@ -115,6 +117,45 @@ TASK_UPDATE_FIELD_MAP = {
}
def _apply_phase_fields(
hass: HomeAssistant,
entry: ConfigEntry,
task_data: dict[str, Any],
raw_defs: object,
raw_sequence: object,
) -> None:
"""Sanitize + store the phase config (#139) — shared by create and update.
Empty/invalid defs or sequence remove BOTH fields (a task either has a
usable cycle or none). Per-phase consumes_parts get the same part-registry
validation the task-level field gets.
"""
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
from ..helpers.phases import sanitize_phase_defs, sanitize_phase_sequence
from . import foreign_part_resolver
defs = sanitize_phase_defs(raw_defs)
for definition in defs.values():
if "consumes_parts" in definition:
links = sanitize_consumes_parts(
definition["consumes_parts"],
set(entry.data.get(CONF_PARTS) or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
if links:
definition["consumes_parts"] = links
else:
del definition["consumes_parts"]
sequence = sanitize_phase_sequence(raw_sequence, defs)
if defs and sequence:
task_data["phases"] = defs
task_data["phase_sequence"] = sequence
else:
task_data.pop("phases", None)
task_data.pop("phase_sequence", None)
# Hoisted as a module constant so the schema/field-map parity tripwire
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
@@ -155,6 +196,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
# Shape-validated in helpers/phases.py at both write paths.
vol.Optional("phases"): vol.Any(dict, None),
vol.Optional("phase_sequence"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
vol.Optional("checklist"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
@@ -337,6 +382,8 @@ async def ws_create_task(
)
if links:
task_data["consumes_parts"] = links
if msg.get("phases") is not None or msg.get("phase_sequence") is not None:
_apply_phase_fields(hass, entry, task_data, msg.get("phases"), msg.get("phase_sequence"))
if msg.get("checklist"):
task_data["checklist"] = msg["checklist"]
if msg.get("labels"):
@@ -422,6 +469,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
# Shape-validated in helpers/phases.py at both write paths.
vol.Optional("phases"): vol.Any(dict, None),
vol.Optional("phase_sequence"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
vol.Optional("checklist"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
@@ -540,6 +591,24 @@ async def ws_update_task(
foreign_part_ids=foreign_part_resolver(hass),
)
# Phases (#139): the raw field-map copy above skipped validation (the
# raw-field-map lesson) — sanitize here, and clamp the Store cursor
# against the possibly shortened sequence (the Store wins on restore, so
# an out-of-range cursor would otherwise survive the edit forever).
if "phases" in msg or "phase_sequence" in msg:
_apply_phase_fields(hass, entry, task, msg.get("phases", task.get("phases")), msg.get("phase_sequence", task.get("phase_sequence")))
from ..helpers.phases import clamp_phase_cursor
rd_phase = _get_runtime_data(hass, msg["entry_id"])
if rd_phase and rd_phase.store:
seq = task.get("phase_sequence") or []
if seq:
cur = rd_phase.store.get_task_state(task_id).get("phase_cursor", 0)
rd_phase.store.set_phase_cursor(task_id, clamp_phase_cursor(cur, len(seq)))
else:
rd_phase.store.get_task_state(task_id).pop("phase_cursor", None)
rd_phase.store.async_delay_save()
# Recurrence resolution: an explicit nested `schedule` wins (calendar kinds
# and kind-switches). Otherwise rebuild from the flat view ONLY when a real
# legacy recurrence signal is present — a flat interval/due field, or a legacy