185 files

This commit is contained in:
Home Assistant Version Control
2026-08-19 11:38:13 +00:00
parent 5f9330abd7
commit a718af6423
185 changed files with 16868 additions and 3088 deletions
@@ -2,12 +2,14 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from ..const import (
CONF_TASKS,
@@ -88,6 +90,10 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
None,
),
vol.Optional("feedback"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
# #133: when the maintenance was actually performed (ISO datetime,
# naive = local). Backfills a past completion; must not be in the
# future — the coordinator validates and splits latest-vs-backfill.
vol.Optional("completed_at"): vol.Any(vol.All(str, vol.Length(max=64)), None),
# Optional completion photo: the doc_id of an already-uploaded image
# (via the document upload endpoint, tagged "photo").
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
@@ -135,7 +141,26 @@ async def ws_complete_task(
return
rd, _entry = ctx
if _completion_blocked(rd, msg["task_id"]):
# #133: an optional backdated completion moment. Parsed here (the schema
# can only cheaply cap the length); range/future validation lives in the
# coordinator choke point shared with the HA service.
completed_at: datetime | None = None
if msg.get("completed_at"):
completed_at = dt_util.parse_datetime(msg["completed_at"])
if completed_at is None:
connection.send_error(
msg["id"],
"invalid_format",
f"completed_at is not a valid ISO datetime: {msg['completed_at']!r}",
)
return
# The earliest-completion window guards against doing the work too early
# — recording a completion that already happened on a PAST day is a
# history correction, not early work, so it bypasses the gate. A
# today-dated completed_at still honours it.
is_past_dated = completed_at is not None and completed_at.date() < dt_util.now().date()
if not is_past_dated and _completion_blocked(rd, msg["task_id"]):
connection.send_error(
msg["id"],
"too_early",
@@ -170,6 +195,7 @@ async def ws_complete_task(
reading_value=msg.get("reading_value"),
restock_quantity=msg.get("restock_quantity"),
used_parts=used_parts,
completed_at=completed_at,
# Who did it: taken from the authenticated connection, never from
# the payload — a client must not be able to credit someone else.
# This is also what feeds the `least_completed` rotation strategy
@@ -177,10 +203,11 @@ async def ws_complete_task(
completed_by=connection.user.id if connection.user else None,
)
except ServiceValidationError as err:
# Required completion details are missing. The dialog normally
# prevents this, so reaching here means an older/cached frontend or a
# scripted call — answer with the field list rather than a traceback.
connection.send_error(msg["id"], "completion_details_required", str(err))
# Validation refusals (missing required details, future completed_at).
# The dialog normally prevents these, so reaching here means an
# older/cached frontend or a scripted call — answer with the
# exception's own key rather than a traceback.
connection.send_error(msg["id"], err.translation_key or "completion_details_required", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -10,12 +10,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
LIFECYCLE_HISTORY_TYPES,
MAX_COST,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_TEXT_LENGTH,
HistoryEntryType,
)
from ..helpers.permissions import require_write
from . import (
@@ -156,15 +156,10 @@ async def ws_update_history_entry(
store.set_history(task_id, history)
# Recompute last_performed if the edited entry is the latest lifecycle
# entry. Lifecycle = anything that resets the maintenance cycle:
# COMPLETED, RESET, SKIPPED. Trigger / trigger_replaced entries don't
# affect last_performed.
LIFECYCLE_TYPES = {
HistoryEntryType.COMPLETED,
HistoryEntryType.RESET,
HistoryEntryType.SKIPPED,
}
lifecycle_entries = [h for h in history if h.get("type") in LIFECYCLE_TYPES]
# entry. Lifecycle = anything that resets the maintenance cycle (shared
# set with the backdated-completion path, #133). Trigger /
# trigger_replaced entries don't affect last_performed.
lifecycle_entries = [h for h in history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
if lifecycle_entries:
# "Latest" by timestamp — sort defensively (entries are usually
# already in append order, but a timestamp edit may have changed that).
@@ -101,6 +101,8 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
# threshold
"trigger_above",
"trigger_below",
"trigger_equals",
"trigger_not_equals",
"trigger_for_minutes",
# counter
"trigger_target_value",
@@ -118,6 +120,8 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
"conditions",
# record a completion when the trigger clears itself (#53)
"auto_complete_on_recovery",
# trigger ∧/∨ safety interval (any = whichever first, all = both required)
"trigger_combinator",
}
@@ -172,10 +176,16 @@ def _validate_trigger_config(
if trigger_config.get(field) is None:
errors.append(f"trigger_config.{field} is required for type '{trigger_type}'")
# Threshold: at least one of trigger_above or trigger_below
# Threshold: at least one limit must be configured
if trigger_type == "threshold":
if trigger_config.get("trigger_above") is None and trigger_config.get("trigger_below") is None:
errors.append("trigger_config requires at least one of 'trigger_above' or 'trigger_below' for type 'threshold'")
_limit_keys = ("trigger_above", "trigger_below", "trigger_equals", "trigger_not_equals")
if all(trigger_config.get(k) is None for k in _limit_keys):
errors.append(
"trigger_config requires at least one of 'trigger_above', 'trigger_below', "
"'trigger_equals' or 'trigger_not_equals' for type 'threshold'"
)
_validate_combinator(trigger_config, errors)
# Runtime: validate trigger_on_states if provided
if trigger_type == "runtime":
@@ -215,6 +225,17 @@ def _validate_trigger_config(
return errors, warnings
def _validate_combinator(trigger_config: dict[str, Any], errors: list[str]) -> None:
"""Validate the trigger ∧/∨ safety-interval combinator (any | all).
Shared by the plain and compound paths — the combinator is task-level and
legal on every trigger type.
"""
combinator = trigger_config.get("trigger_combinator")
if combinator is not None and combinator not in ("any", "all"):
errors.append(f"trigger_config.trigger_combinator must be 'any' or 'all', got '{combinator}'")
def _validate_compound_trigger(
hass: HomeAssistant,
trigger_config: dict[str, Any],
@@ -223,6 +244,8 @@ def _validate_compound_trigger(
errors: list[str] = []
warnings: list[str] = []
_validate_combinator(trigger_config, errors)
compound_logic = trigger_config.get("compound_logic", "AND").upper()
if compound_logic not in ("AND", "OR"):
errors.append(f"compound_logic must be 'AND' or 'OR', got '{compound_logic}'")