185 files
This commit is contained in:
@@ -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}'")
|
||||
|
||||
Reference in New Issue
Block a user