184 files

This commit is contained in:
Home Assistant Version Control
2026-09-02 11:49:09 +00:00
parent 7b47edbf8d
commit 0c64e47969
184 changed files with 9608 additions and 11572 deletions
@@ -156,6 +156,8 @@ def _build_task_summary(
"custom_icon": task_data.get("custom_icon"),
"nfc_tag_id": task_data.get("nfc_tag_id"),
"require_tag_scan": bool(task_data.get("require_tag_scan", False)),
# #150: skip lock — False disables skipping on every surface.
"allow_skip": task_data.get("allow_skip") is not False,
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
"reading_unit": task_data.get("reading_unit"),
# Spare parts: consumption links ([{part_id, quantity}]) and, on an
@@ -714,6 +714,7 @@ async def ws_import_json(
"custom_icon",
"nfc_tag_id",
"require_tag_scan",
"allow_skip",
"responsible_user_id",
"entity_slug",
"trigger_config",
@@ -313,11 +313,16 @@ async def ws_skip_task(
return
rd, _entry = ctx
await rd.coordinator.skip_maintenance(
task_id=msg["task_id"],
reason=msg.get("reason"),
as_missed=msg.get("as_missed", False),
)
try:
await rd.coordinator.skip_maintenance(
task_id=msg["task_id"],
reason=msg.get("reason"),
as_missed=msg.get("as_missed", False),
)
except ServiceValidationError as err:
# #150: the task carries a skip lock.
connection.send_error(msg["id"], "skip_disabled", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -104,6 +104,7 @@ TASK_UPDATE_FIELD_MAP = {
"custom_icon": "custom_icon",
"nfc_tag_id": "nfc_tag_id",
"require_tag_scan": "require_tag_scan",
"allow_skip": "allow_skip",
"reading_unit": "reading_unit",
"consumes_parts": "consumes_parts",
"phases": "phases",
@@ -195,6 +196,9 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# Proof of presence (#139 family): completion only via NFC/QR scan.
vol.Optional("require_tag_scan"): vol.Any(bool, None),
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
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}].
@@ -372,6 +376,9 @@ async def ws_create_task(
tc_warnings.append(nfc_warn)
if msg.get("require_tag_scan") is not None:
task_data["require_tag_scan"] = bool(msg["require_tag_scan"])
# #150: stored only when False — absence means skipping is allowed.
if msg.get("allow_skip") is False:
task_data["allow_skip"] = False
# v2.20 (#83): unit for `reading`-type tasks.
if msg.get("reading_unit") is not None:
task_data["reading_unit"] = (msg["reading_unit"] or "").strip() or None
@@ -472,6 +479,9 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# Proof of presence (#139 family): completion only via NFC/QR scan.
vol.Optional("require_tag_scan"): vol.Any(bool, None),
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
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}].
@@ -582,6 +592,14 @@ async def ws_update_task(
if msg_key in msg:
task[data_key] = msg[msg_key]
# #150: allow_skip is stored only when False (absence = allowed) — the
# verbatim copy above would persist True/None literals.
if "allow_skip" in msg:
if msg["allow_skip"] is False:
task["allow_skip"] = False
else:
task.pop("allow_skip", None)
# The loop above copies values verbatim, which is wrong for part links:
# `task/create` validates them and `task/update` did not, so an edit could
# persist a link to a part — or, since #111, to an OBJECT — that does not
@@ -113,6 +113,7 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
# runtime
"trigger_runtime_hours",
"trigger_on_states",
"trigger_runtime_max_session_seconds",
# state_change
"trigger_from_state",
"trigger_to_state",
@@ -322,6 +323,18 @@ def _validate_trigger_values(trigger_type: str, trigger_config: dict[str, Any],
elif isinstance(raw, str):
trigger_config["trigger_runtime_hours"] = num
# #149: per-session cap against stuck sensors — whole seconds, 1..86400.
raw = trigger_config.get("trigger_runtime_max_session_seconds")
if raw is not None:
num = _coerce_number(raw)
if num is None or not num.is_integer() or not 1 <= num <= 86400:
errors.append(
f"trigger_config.trigger_runtime_max_session_seconds must be a whole number "
f"between 1 and 86400 (seconds), got {raw!r}"
)
else:
trigger_config["trigger_runtime_max_session_seconds"] = int(num)
raw = trigger_config.get("trigger_delta_mode")
if raw is not None and not isinstance(raw, bool):
errors.append(f"trigger_config.trigger_delta_mode must be true or false, got {raw!r}")