393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -17,13 +17,13 @@ from ..const import (
MAX_COST,
MAX_DATE_LENGTH,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_TEXT_LENGTH,
MAX_TIMESTAMP_LENGTH,
)
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
from ..models.maintenance_task import MaintenanceTask
from . import (
ID_FIELD,
READING_VALUES_FIELD,
USED_PARTS_FIELD,
_load_object_task,
@@ -66,8 +66,8 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/complete",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), None),
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), None),
@@ -93,10 +93,10 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
# images (via the document upload endpoint, tagged "photo"). The
# scalar form is what pre-2.75 clients send — merged into the list.
vol.Optional("photo_doc_ids"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
vol.All([ID_FIELD], vol.Length(max=MAX_COMPLETION_PHOTOS)),
None,
),
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("photo_doc_id"): vol.Any(ID_FIELD, None),
# Meter readings (v2.20, #83): the recorded value for `reading` tasks.
# Wide numeric bounds — meters count high, temperatures go negative.
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
@@ -177,6 +177,17 @@ async def ws_complete_task(
connection.send_error(msg["id"], "invalid_input", str(err))
return
# Only this object's uploaded files count as completion photos — a
# foreign doc id satisfied a required photo and got linked (and later
# re-homed by task/move). This command is the one completion surface
# that carries photos (bug audit 2026-09-26).
from ..helpers.completion_requirements import own_photo_doc_ids
from . import object_id_for_entry
photo_doc_ids = own_photo_doc_ids(
hass, object_id_for_entry(_entry), normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id"))
)
try:
await rd.coordinator.complete_maintenance(
source="panel",
@@ -186,7 +197,7 @@ async def ws_complete_task(
duration=msg.get("duration"),
checklist_state=msg.get("checklist_state"),
feedback=msg.get("feedback"),
photo_doc_ids=normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id")) or None,
photo_doc_ids=photo_doc_ids or None,
reading_value=msg.get("reading_value"),
reading_values=reading_values,
restock_quantity=msg.get("restock_quantity"),
@@ -219,8 +230,8 @@ async def ws_complete_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/quick_complete",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -273,8 +284,8 @@ async def ws_quick_complete_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/skip",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Optional("reason"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
# Record the skipped cycle as MISSED (was due, never done) rather than a
# deliberate skip — clearer history + compliance views.
@@ -310,8 +321,8 @@ async def ws_skip_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/reset",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Optional("date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@@ -329,7 +340,10 @@ async def ws_reset_task(
reset_date = None
if msg.get("date"):
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date")
# A reset marks a day the work WAS done — a future one (read tier:
# any household member) overflowed the schedule math and took the
# object down (bug audit 2026-09-27).
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date", not_future=True)
if reset_date is None:
return
@@ -348,8 +362,8 @@ async def ws_reset_task(
@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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
# Index into phase_sequence — which cycle step is due NEXT.
vol.Required("cursor"): vol.All(int, vol.Range(min=0, max=100)),
}
@@ -387,8 +401,8 @@ async def ws_set_task_phase(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/postpone",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Required("until"): vol.All(str, vol.Length(max=MAX_DATE_LENGTH)),
}
)
@@ -407,6 +421,15 @@ async def ws_postpone_task(
until = _parse_iso_date(connection, msg["id"], msg["until"], field="until")
if until is None:
return
# Bug audit 2026-09-27: an override in year 9999 overflowed the calendar
# entity's "next day" — a postpone reaches at most one maximum interval out.
from datetime import timedelta
from ..const import MAX_INTERVAL_DAYS
if until > dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS):
connection.send_error(msg["id"], "invalid_date", f"until must be within {MAX_INTERVAL_DAYS} days")
return
try:
await rd.coordinator.async_postpone_task(msg["task_id"], until)
@@ -419,8 +442,8 @@ async def ws_postpone_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/snooze",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -435,7 +458,7 @@ async def ws_snooze_task(
due-soon/overdue/triggered reminders for ``snooze_duration_hours`` — it does
not change the task's schedule or state.
"""
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
from ..const import DOMAIN, NOTIFICATION_MANAGER_KEY
if _load_object_task(hass, connection, msg) is None:
return
@@ -451,8 +474,8 @@ async def ws_snooze_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/checklist_progress",
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)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
# Same shape/caps as task/complete's checklist_state.
vol.Required("checklist_state"): vol.All(
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
@@ -478,11 +501,16 @@ async def ws_checklist_progress(
a step IS doing the work — the same household member who may complete the
task must be able to record partial progress.
"""
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
from ..helpers.phases import effective_field
# Merged view: the phase cursor lives in the Store. A phased task (#139)
# shows the CURRENT phase's checklist; validating against the task-level
# list dropped every tick on it (bug audit 2026-09-26).
ctx = _load_object_task(hass, connection, msg, merged=True, need_coordinator=True)
if ctx is None:
return
_entry, rd, task = ctx
task_items = set(task.get("checklist") or [])
task_items = set(effective_field(task, "checklist") or [])
state = {item: bool(done) for item, done in msg["checklist_state"].items() if item in task_items}
# Progress lives ONLY in the Store (no legacy fallback) — degrade to a
# clean error instead of an AttributeError when it failed to load.