updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -131,6 +131,10 @@ def _build_task_summary(
"nfc_tag_id": task_data.get("nfc_tag_id"),
# 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
# auto-created "buy" reminder, the owning part marker ({part_id}).
"consumes_parts": task_data.get("consumes_parts"),
"part_ref": task_data.get("part_ref"),
"priority": task_data.get("priority", "normal"),
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
# `archived` is the convenience bool the frontend filters on; reason is
@@ -229,12 +233,44 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
tasks = [_build_task_summary(hass, tid, tdata, ct_tasks.get(tid), object_slug) for tid, tdata in tasks_data.items()]
# (roadmap P2) attached-document count for the objects-table paperclip badge.
# (roadmap P2) attached-document count for the objects-table paperclip badge,
# plus a per-task count so each task row can carry its own document badge.
from .. import DOCUMENT_STORE_KEY
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
object_id = obj_data.get("id", "")
document_count = len(doc_store.for_object(object_id)) if doc_store is not None and object_id else 0
object_docs = doc_store.for_object(object_id) if doc_store is not None and object_id else []
document_count = len(object_docs)
task_doc_counts: dict[str, int] = {}
for _doc in object_docs:
for _tid in _doc.get("task_ids") or []:
task_doc_counts[_tid] = task_doc_counts.get(_tid, 0) + 1
for _task in tasks:
_task["document_count"] = task_doc_counts.get(_task["id"], 0)
# Spare parts: full definition + merged stock + the derived helpers the
# panel renders (is_low, resolved shopping URL). EVERY persisted field is
# exposed (#50 field-completeness class).
from ..const import CONF_PART_SEARCH_URL_TEMPLATE, CONF_PARTS
from ..helpers.global_options import get_global_options
from ..helpers.i18n import normalize_language
from ..helpers.parts import part_is_low, resolve_shopping_url
rd_parts = getattr(entry, "runtime_data", None)
part_store = getattr(rd_parts, "store", None) if rd_parts else None
search_template = get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE)
lang = normalize_language(hass)
parts_payload = []
for part in (entry.data.get(CONF_PARTS) or {}).values():
stock = part_store.get_part_stock(part["id"]) if part_store else None
parts_payload.append(
{
**part,
"stock": stock,
"is_low": part_is_low(part, stock),
"shopping_url": resolve_shopping_url(part, search_template, lang),
}
)
return {
"entry_id": entry.entry_id,
@@ -274,15 +310,15 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
"document_count": document_count,
},
"tasks": tasks,
"parts": parts_payload,
}
def _get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""Get the global config entry."""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return entry
return None
"""Get the global config entry (thin re-export of the shared helper)."""
from ..helpers.global_options import get_global_entry
return get_global_entry(hass)
def _load_object_entry(
@@ -434,6 +470,21 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_unarchive_object,
ws_update_object,
)
from .parts import (
ws_create_part,
ws_delete_part,
ws_restock_part,
ws_update_part,
)
from .problem_sensors import (
ws_adopt_problem_sensors,
ws_discover_problem_sensors,
)
from .saved_views import (
ws_delete_saved_view,
ws_list_saved_views,
ws_save_saved_view,
)
from .tags import ws_list_tags
from .tasks import (
ws_archive_task,
@@ -486,6 +537,10 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_reset_task)
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)
websocket_api.async_register_command(hass, ws_update_part)
websocket_api.async_register_command(hass, ws_delete_part)
websocket_api.async_register_command(hass, ws_restock_part)
websocket_api.async_register_command(hass, ws_update_history_entry)
websocket_api.async_register_command(hass, ws_get_templates)
websocket_api.async_register_command(hass, ws_export_data)
@@ -494,6 +549,11 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_export_objects_csv)
websocket_api.async_register_command(hass, ws_import_csv)
websocket_api.async_register_command(hass, ws_import_json)
websocket_api.async_register_command(hass, ws_discover_problem_sensors)
websocket_api.async_register_command(hass, ws_adopt_problem_sensors)
websocket_api.async_register_command(hass, ws_list_saved_views)
websocket_api.async_register_command(hass, ws_save_saved_view)
websocket_api.async_register_command(hass, ws_delete_saved_view)
websocket_api.async_register_command(hass, ws_get_groups)
websocket_api.async_register_command(hass, ws_create_group)
websocket_api.async_register_command(hass, ws_update_group)
@@ -16,6 +16,7 @@ from ..const import (
MAX_META_LENGTH,
)
from ..helpers.permissions import require_write
from ..helpers.task_fields import INTERVAL_DAYS_RANGE
from . import _get_merged_tasks, _get_runtime_data, _load_object_entry
@@ -87,7 +88,7 @@ async def ws_analyze_interval(
vol.Required("type"): f"{DOMAIN}/task/apply_suggestion",
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("interval"): vol.All(int, vol.Range(min=1, max=3650)),
vol.Required("interval"): vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])),
}
)
@require_write
@@ -121,7 +122,9 @@ async def ws_apply_suggestion(
vol.Required("type"): f"{DOMAIN}/task/seasonal_overrides",
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("overrides"): dict,
# At most 12 month keys survive validation anyway — cap the input so a
# giant dict isn't iterated/coerced first (parity with every other list).
vol.Required("overrides"): vol.All(dict, vol.Length(max=12)),
}
)
@require_write
@@ -45,6 +45,7 @@ from ..const import (
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SCOPE_VIEW_ID,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
@@ -167,6 +168,9 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
"title_style": options.get(CONF_NOTIFICATION_TITLE_STYLE, "default"),
# Multiple lead-time reminders (days before due); [] = off.
"reminder_lead_days": options.get(CONF_REMINDER_LEAD_DAYS, []),
# v2.26: notification routing — saved-view id scoping which
# tasks may notify ("" = all tasks).
"scope_view_id": options.get(CONF_NOTIFY_SCOPE_VIEW_ID, ""),
},
"actions": {
"complete_enabled": options.get(CONF_ACTION_COMPLETE_ENABLED, False),
@@ -141,6 +141,8 @@ async def ws_documents_add_link(
vol.Optional("tags"): _TAGS_SCHEMA,
vol.Optional("task_ids"): _TASK_IDS_SCHEMA,
vol.Optional("task_pages"): _TASK_PAGES_SCHEMA,
# Spare-part links (v2.26) — same shape/cap as task links.
vol.Optional("part_ids"): _TASK_IDS_SCHEMA,
}
)
@require_write
@@ -162,6 +164,7 @@ async def ws_documents_update(
tags=msg.get("tags"),
task_ids=msg.get("task_ids"),
task_pages=msg.get("task_pages"),
part_ids=msg.get("part_ids"),
)
if not ok:
connection.send_error(msg["id"], "not_found", "Document not found")
@@ -24,6 +24,8 @@ from ..const import (
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_ID_LENGTH,
MAX_IMPORT_PAYLOAD_BYTES,
MAX_JSON_IMPORT_PAYLOAD_BYTES,
)
from ..helpers.global_options import get_default_warning_days
from ..helpers.qr_generator import (
@@ -37,6 +39,55 @@ from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
_LOGGER = logging.getLogger(__name__)
def _iso_marker(value: Any) -> str | None:
"""Keep ``value`` only if it parses as an ISO date/datetime, else drop it.
``paused_at`` is a *marker* whose mere presence means "paused"; a garbage
value imported from a hand-edited/foreign backup would otherwise freeze the
object as paused forever (and a malformed ``paused_until`` means auto-resume
never fires). Validate on import so only a real timestamp restores the state.
"""
from datetime import date, datetime
if not isinstance(value, str) or not value.strip():
return None
s = value.strip()
try:
datetime.fromisoformat(s.replace("Z", "+00:00"))
return s
except ValueError:
try:
date.fromisoformat(s)
return s
except ValueError:
return None
def _sanitize_history(history: Any) -> list[dict[str, Any]]:
"""Scrub imported history entries: drop a non-finite/negative ``cost``.
Every live write path range-guards cost, but import copied history verbatim
and ``json.loads``/``yaml.safe_load`` both accept ``NaN``/``Infinity``. Such
a value would poison budget aggregation (a `+inf` fake "budget exceeded"
alert, or `nan` silently disabling all alerts). The completion still counts;
only the bad cost is removed.
"""
import math
if not isinstance(history, list):
return []
out: list[dict[str, Any]] = []
for entry in history:
if not isinstance(entry, dict):
continue
clean = dict(entry)
cost = clean.get("cost")
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
clean.pop("cost", None)
out.append(clean)
return out
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/templates",
@@ -98,6 +149,8 @@ async def ws_get_templates(
vol.Required("type"): f"{DOMAIN}/export",
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
vol.Optional("include_history", default=True): bool,
# Selective export: restrict to these object entry_ids (omit = all).
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
}
)
@websocket_api.require_admin
@@ -107,14 +160,15 @@ async def ws_export_data(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all maintenance data as JSON or YAML."""
"""Export all (or a selection of) maintenance data as JSON or YAML."""
from ..export import build_export_data, serialize_export
fmt = msg.get("format", "json")
include_history = msg.get("include_history", True)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
# Phase 1: gather data on the event loop (accesses HA APIs)
data = build_export_data(hass, include_history=include_history)
data = build_export_data(hass, include_history=include_history, entry_ids=entry_ids)
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
result = await hass.async_add_executor_job(serialize_export, data, fmt)
@@ -122,7 +176,12 @@ async def ws_export_data(
connection.send_result(msg["id"], {"format": fmt, "data": result})
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/csv/export"})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/csv/export",
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_csv(
@@ -130,28 +189,35 @@ async def ws_export_csv(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all maintenance data as CSV."""
"""Export all (or a selection of) maintenance data as CSV."""
from ..helpers.csv_handler import export_objects_csv
csv_data = export_objects_csv(hass)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_objects_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/objects/csv"})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/objects/csv",
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
}
)
@websocket_api.async_response
async def ws_export_objects_csv(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export one row per maintenance object as CSV (#67).
"""Export one row per maintenance object as CSV (#67), all or a selection.
Not admin-gated: it exposes only the asset fields the panel already sends
to every user via ``maintenance_supporter/objects`` (no cost/history).
"""
from ..helpers.csv_handler import export_object_records_csv
csv_data = export_object_records_csv(hass)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_object_records_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@@ -173,7 +239,7 @@ async def ws_import_csv(
csv_content = msg["csv_content"]
# Guard against oversized payloads (max 1MB / 1000 objects)
if len(csv_content) > 1_048_576:
if len(csv_content) > MAX_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
return
@@ -274,7 +340,7 @@ async def ws_import_json(
) -> None:
"""Import maintenance objects from JSON or YAML content (from /export)."""
raw = msg["json_content"]
if len(raw) > 10_485_760:
if len(raw) > MAX_JSON_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
return
@@ -340,14 +406,50 @@ async def ws_import_json(
# 2.20: seasonal pause round-trips (a paused pool restored in
# winter stays paused); replace-flow lineage ids are the same
# instance-specific story as parent_entry_id above.
"paused_at": obj_data.get("paused_at"),
"paused_until": obj_data.get("paused_until"),
"paused_at": _iso_marker(obj_data.get("paused_at")),
"paused_until": _iso_marker(obj_data.get("paused_until")),
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
"task_ids": [],
}
# Spare parts: regenerate ids (like tasks) and remember the mapping so
# task-side links (consumes_parts / part_ref) can be rewritten below.
# Stock is dynamic Store state — collected here, written after setup.
from uuid import uuid4 as _uuid4
part_id_map: dict[str, str] = {}
import_parts: dict[str, dict[str, Any]] = {}
part_stocks: dict[str, int] = {}
parts_list = obj_entry.get("parts", [])
if isinstance(parts_list, list):
for part_entry in parts_list:
if not isinstance(part_entry, dict) or not (part_entry.get("name") or "").strip():
continue
old_id = str(part_entry.get("id") or "")
new_id = _uuid4().hex
pdata = {k: v for k, v in part_entry.items() if k != "stock"}
pdata["id"] = new_id
# Drop a non-http(s) product_url — the WS write path validates it
# via _clean_url, but import copied it verbatim, so a crafted
# backup could persist a javascript: link (the panel now also
# guards the href, but keep bad data out of storage).
_purl = pdata.get("product_url")
if isinstance(_purl, str) and _purl.strip().lower().startswith(("http://", "https://")):
pdata["product_url"] = _purl.strip() # store trimmed so the render guard matches
else:
pdata.pop("product_url", None)
import_parts[new_id] = pdata
if old_id:
part_id_map[old_id] = new_id
stock = part_entry.get("stock")
if isinstance(stock, int) and stock >= 0:
part_stocks[new_id] = stock
import_tasks: dict[str, dict[str, Any]] = {}
# old task id → new id, so document task-links (task_ids) can be
# remapped onto the freshly generated tasks (mirrors part_id_map).
task_id_map: dict[str, str] = {}
tasks_list = obj_entry.get("tasks", [])
if not isinstance(tasks_list, list):
tasks_list = []
@@ -358,6 +460,9 @@ async def ws_import_json(
if not task_name:
continue
task_id = uuid4().hex
old_task_id = str(task_entry.get("id") or "")
if old_task_id:
task_id_map[old_task_id] = task_id
task_data: dict[str, Any] = {
"id": task_id,
"object_id": obj_id,
@@ -366,9 +471,15 @@ async def ws_import_json(
"enabled": task_entry.get("enabled", True),
"schedule_type": task_entry.get("schedule_type", "time_based"),
"warning_days": task_entry.get("warning_days", get_default_warning_days(hass)),
"history": task_entry.get("history", []),
"history": _sanitize_history(task_entry.get("history", [])),
}
for key in (
# Provenance + lifecycle — mirror the export builder so an
# archived task stays archived and created_at (the next_due
# fallback anchor) survives the round trip.
"created_at",
"archived_at",
"archived_reason",
"interval_days",
"interval_unit",
"due_date",
@@ -400,11 +511,34 @@ async def ws_import_json(
"assignee_pool",
"rotation_strategy",
"reading_unit",
# spare parts (ids remapped below)
"consumes_parts",
"part_ref",
):
val = task_entry.get(key)
if val is not None:
task_data[key] = val
# Remap part links to the regenerated part ids; drop dangling ones.
links = task_data.get("consumes_parts")
if isinstance(links, list):
remapped = [
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
for link in links
if isinstance(link, dict) and link.get("part_id") in part_id_map
]
if remapped:
task_data["consumes_parts"] = remapped
else:
task_data.pop("consumes_parts", None)
elif links is not None:
task_data.pop("consumes_parts", None)
ref = task_data.get("part_ref")
if isinstance(ref, dict) and ref.get("part_id") in part_id_map:
task_data["part_ref"] = {"part_id": part_id_map[ref["part_id"]]}
elif ref is not None:
task_data.pop("part_ref", None)
# 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):
@@ -469,6 +603,7 @@ async def ws_import_json(
data={
CONF_OBJECT: import_obj,
CONF_TASKS: import_tasks,
"parts": import_parts,
},
)
except Exception:
@@ -485,6 +620,16 @@ async def ws_import_json(
entry_info["warnings"] = nfc_warnings
created.append(entry_info)
# Restore tracked part stocks into the new entry's Store.
if part_stocks:
new_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
rd_new = getattr(new_entry, "runtime_data", None) if new_entry else None
store_new = getattr(rd_new, "store", None) if rd_new else None
if store_new is not None:
for pid, stock_val in part_stocks.items():
store_new.set_part_stock(pid, stock_val)
await store_new.async_save()
# (roadmap P6) recreate document metadata + web-links for the object
# (blobs travel via the /config backup; a JSON-only import leaves
# file docs dangling, which the storage-hygiene repair issue catches).
@@ -494,7 +639,9 @@ async def ws_import_json(
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None:
await doc_store.async_import_documents(obj_id, import_docs)
await doc_store.async_import_documents(
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map
)
else:
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
@@ -515,7 +662,7 @@ async def ws_import_json(
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]),
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
vol.Optional("base_url"): vol.Url(),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response
@@ -613,7 +760,7 @@ def _cached_qr_svg(url: str, icon: str | None) -> str:
vol.Length(min=1, max=4),
),
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
vol.Optional("base_url"): vol.Url(),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response
@@ -851,8 +851,24 @@ async def ws_replace_object(
new_obj.pop(key, None)
new_obj["predecessor_entry_id"] = entry.entry_id
# Carry the parts shelf — the spares don't change when the machine dies.
# Fresh ids (like tasks); consumption links are remapped below and the
# tracked stock is copied into the successor's store after creation.
part_id_map: dict[str, str] = {}
new_parts: dict[str, Any] = {}
for src_part in (entry.data.get("parts") or {}).values():
carried = dict(src_part)
new_pid = uuid4().hex
part_id_map[str(carried.get("id"))] = new_pid
carried["id"] = new_pid
new_parts[new_pid] = carried
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
# Auto "buy" reminders are transient reconciler-owned state — the
# successor's own reconcile recreates one if the carried part is low.
if src_task.get("part_ref"):
continue
task = deepcopy(dict(src_task))
task_id = uuid4().hex
task["id"] = task_id
@@ -870,13 +886,24 @@ async def ws_replace_object(
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
links = task.get("consumes_parts")
if isinstance(links, list):
remapped = [
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
for link in links
if isinstance(link, dict) and link.get("part_id") in part_id_map
]
if remapped:
task["consumes_parts"] = remapped
else:
task.pop("consumes_parts", None)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks, "parts": new_parts},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "replace_failed", result.get("reason", "unknown"))
@@ -892,7 +919,29 @@ async def ws_replace_object(
if doc_store is not None:
src_docs = doc_store.for_object(object_id_for_entry(entry))
if src_docs:
await doc_store.async_import_documents(new_obj["id"], src_docs)
# part_id_map keeps a doc's spare-part links pointing at the carried
# parts' fresh ids (task links intentionally drop — the successor's
# tasks restart fresh).
await doc_store.async_import_documents(new_obj["id"], src_docs, part_id_map=part_id_map)
# Copy the tracked stock counts (dynamic store state) onto the carried
# parts, then let the successor's reconcile recreate any needed reminder.
if part_id_map:
src_rd = getattr(entry, "runtime_data", None)
src_store = getattr(src_rd, "store", None) if src_rd else None
new_entry = hass.config_entries.async_get_entry(new_entry_id)
new_rd = getattr(new_entry, "runtime_data", None) if new_entry else None
new_store = getattr(new_rd, "store", None) if new_rd else None
if src_store is not None and new_store is not None:
for old_pid, new_pid in part_id_map.items():
stock = src_store.get_part_stock(old_pid)
if stock is not None:
new_store.set_part_stock(new_pid, stock)
await new_store.async_save()
if new_entry is not None:
from ..parts_runtime import schedule_buy_task_reconcile
schedule_buy_task_reconcile(hass, new_entry)
# Retire the predecessor (archive cascade) with the successor pointer.
now_iso = dt_util.now().isoformat()
@@ -0,0 +1,253 @@
"""WebSocket handlers for spare parts & consumables (part/*)."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_PARTS, MAX_ID_LENGTH
from ..helpers.parts import (
MAX_PART_STOCK,
MAX_PARTS_PER_OBJECT,
PartValidationError,
normalize_part,
)
from ..helpers.permissions import require_write
from . import _get_runtime_data, _load_object_entry
def _parts_of(entry: Any) -> dict[str, dict[str, Any]]:
parts = entry.data.get(CONF_PARTS)
return dict(parts) if isinstance(parts, dict) else {}
def _persist_parts(hass: HomeAssistant, entry: Any, parts: dict[str, dict[str, Any]]) -> None:
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
hass.config_entries.async_update_entry(entry, data=new_data)
_PART_FIELDS_SCHEMA = {
vol.Required("name"): str,
vol.Optional("mpn"): vol.Any(str, None),
vol.Optional("gtin"): vol.Any(str, None),
vol.Optional("vendor"): vol.Any(str, None),
vol.Optional("storage_location"): vol.Any(str, None),
vol.Optional("product_url"): vol.Any(str, None),
vol.Optional("notes"): vol.Any(str, None),
vol.Optional("unit"): vol.Any(str, None),
vol.Optional("cost"): vol.Any(int, float, None),
vol.Optional("reorder_threshold"): vol.Any(int, None),
vol.Optional("restock_quantity"): vol.Any(int, None),
vol.Optional("auto_buy_task"): bool,
vol.Optional("doc_id"): vol.Any(str, None),
# Initial / edited stock travels WITH the definition for dialog simplicity,
# but is stored in the per-entry Store (dynamic), not entry.data.
vol.Optional("stock"): vol.Any(int, None),
}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/create",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
**_PART_FIELDS_SCHEMA,
}
)
@require_write
@websocket_api.async_response
async def ws_create_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Add a part to an object."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
if len(parts) >= MAX_PARTS_PER_OBJECT:
connection.send_error(msg["id"], "limit_reached", f"At most {MAX_PARTS_PER_OBJECT} parts per object")
return
try:
# msg["id"] is the WS envelope's message id — NEVER the part id; force
# a fresh uuid (normalize_part generates one when id is falsy).
part = normalize_part({**msg, "id": None})
except PartValidationError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
parts[part["id"]] = part
_persist_parts(hass, entry, parts)
stock = msg.get("stock")
if stock is not None:
from ..parts_runtime import async_change_part_stock
await async_change_part_stock(hass, entry, part["id"], absolute=int(stock))
# Reload so the part's stock sensor appears (entities are created at setup).
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"part_id": part["id"]})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
**_PART_FIELDS_SCHEMA,
}
)
@require_write
@websocket_api.async_response
async def ws_update_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Edit a part definition (and optionally its stock)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
existing = parts.get(msg["part_id"])
if existing is None:
connection.send_error(msg["id"], "not_found", "Part not found")
return
payload = {**existing, **{k: v for k, v in msg.items() if k in _PART_FIELD_KEYS}}
payload["id"] = msg["part_id"]
try:
part = normalize_part(payload)
except PartValidationError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
parts[part["id"]] = part
_persist_parts(hass, entry, parts)
from ..parts_runtime import async_change_part_stock, schedule_buy_task_reconcile
if "stock" in msg:
stock = msg.get("stock")
if stock is None:
rd = _get_runtime_data(hass, entry.entry_id)
if rd and rd.store:
rd.store.set_part_stock(part["id"], None)
await rd.store.async_save()
schedule_buy_task_reconcile(hass, entry)
else:
await async_change_part_stock(hass, entry, part["id"], absolute=int(stock))
else:
# Threshold/opt-in edits can change the desired buy-task set.
schedule_buy_task_reconcile(hass, entry)
connection.send_result(msg["id"], {"success": True})
_PART_FIELD_KEYS = {k.schema for k in _PART_FIELDS_SCHEMA if str(k.schema) != "stock"}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_delete_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a part: definition, stock state, task links, open buy task."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
if msg["part_id"] not in parts:
connection.send_error(msg["id"], "not_found", "Part not found")
return
del parts[msg["part_id"]]
# Prune task-side consumption links pointing at the deleted part.
from ..const import CONF_TASK_CONSUMES_PARTS, CONF_TASKS
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
tasks = dict(new_data.get(CONF_TASKS, {}))
for tid, td in list(tasks.items()):
links = td.get(CONF_TASK_CONSUMES_PARTS)
if isinstance(links, list) and any(isinstance(x, dict) and x.get("part_id") == msg["part_id"] for x in links):
td = dict(td)
td[CONF_TASK_CONSUMES_PARTS] = [
x for x in links if not (isinstance(x, dict) and x.get("part_id") == msg["part_id"])
]
if not td[CONF_TASK_CONSUMES_PARTS]:
td.pop(CONF_TASK_CONSUMES_PARTS, None)
tasks[tid] = td
new_data[CONF_TASKS] = tasks
hass.config_entries.async_update_entry(entry, data=new_data)
rd = _get_runtime_data(hass, entry.entry_id)
if rd and rd.store:
rd.store.remove_part(msg["part_id"])
await rd.store.async_save()
# Remove the part's stock sensor from the entity registry (same
# contained-segment match the task delete uses; part ids are uuid4).
from homeassistant.helpers import entity_registry as er
ent_reg = er.async_get(hass)
for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if ent_entry.unique_id and f"_part_{msg['part_id']}" in ent_entry.unique_id:
ent_reg.async_remove(ent_entry.entity_id)
# The reconcile removes an open buy task for the now-gone part (it reloads
# when it changes anything); reload here regardless so the sensor vanishes.
from ..parts_runtime import schedule_buy_task_reconcile
await hass.config_entries.async_reload(entry.entry_id)
schedule_buy_task_reconcile(hass, entry)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/restock",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# Either a relative delta (may be negative for corrections) or an
# absolute count — exactly one.
vol.Optional("delta"): vol.All(int, vol.Range(min=-MAX_PART_STOCK, max=MAX_PART_STOCK)),
vol.Optional("absolute"): vol.All(int, vol.Range(min=0, max=MAX_PART_STOCK)),
}
)
@require_write
@websocket_api.async_response
async def ws_restock_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Adjust a part's on-hand stock (inventory correction / manual restock)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
if ("delta" in msg) == ("absolute" in msg):
connection.send_error(msg["id"], "invalid_input", "Provide exactly one of delta / absolute")
return
from ..parts_runtime import async_change_part_stock
new = await async_change_part_stock(
hass,
entry,
msg["part_id"],
delta=msg.get("delta"),
absolute=msg.get("absolute"),
)
if new is None:
connection.send_error(msg["id"], "not_found", "Part not found")
return
connection.send_result(msg["id"], {"stock": new})
@@ -0,0 +1,157 @@
"""WebSocket commands to discover + adopt HA problem sensors as tasks.
``problem_sensors/discover`` proposes adoptable ``device_class: problem`` binary
sensors; ``problem_sensors/adopt`` turns an explicit selection into
sensor-triggered tasks (creating a maintenance object per device when needed).
Adoption is admin-gated write; discovery is read (it only lists candidates).
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.permissions import require_write
from ..helpers.problem_sensors import (
build_problem_task,
discover_problem_sensors,
pop_stashed_notes,
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/problem_sensors/discover"})
@websocket_api.async_response
async def ws_discover_problem_sensors(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List adoptable problem sensors (not already watched by a task)."""
connection.send_result(msg["id"], {"sensors": discover_problem_sensors(hass)})
_SELECTION_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
# Existing target object; omit to create a fresh object for this device.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("object_name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("device_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
# Spare part to link as consumes_parts on the adopted task (discovery's
# suggested_part_id) — completing the task then consumes/restocks it.
vol.Optional("part_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
}
)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/problem_sensors/adopt",
vol.Required("selections"): vol.All([_SELECTION_SCHEMA], vol.Length(min=1, max=100)),
}
)
@require_write
@websocket_api.async_response
async def ws_adopt_problem_sensors(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create a sensor-triggered task per selected problem sensor.
Each selection attaches to its ``entry_id`` (an existing object) or, when
omitted, to a freshly created object named ``object_name`` and bound to the
sensor's ``device_id`` — so a second adoption on the same device reuses it.
"""
from ..export import object_entries
from ..websocket.objects import async_create_object
from ..websocket.tasks_persist import async_persist_task
selections = msg["selections"]
tasks_created = 0
objects_created = 0
# Reuse an object created earlier in THIS batch for the same device, so two
# sensors on one device don't spawn two objects.
device_to_entry: dict[str, str] = {}
errors: list[dict[str, str]] = []
for sel in selections:
entity_id = sel["entity_id"]
entry_id = sel.get("entry_id")
device_id = sel.get("device_id")
created_entry_id: str | None = None # object created in THIS iteration
try:
if not entry_id and device_id and device_id in device_to_entry:
entry_id = device_to_entry[device_id]
if not entry_id:
entry_id = await async_create_object(
hass,
name=sel.get("object_name") or sel["name"],
ha_device_id=device_id or None,
)
created_entry_id = entry_id
objects_created += 1
if device_id:
device_to_entry[device_id] = entry_id
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN:
errors.append({"entity_id": entity_id, "reason": "target object not found"})
continue
task = build_problem_task(entity_id, sel["name"])
task_data = {
"id": uuid4().hex,
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
"name": task["name"],
"type": task["task_type"],
"enabled": True,
"schedule": task["schedule"],
"trigger_config": task["trigger_config"],
}
# Un-adopt → re-adopt: restore (and consume) the notes the deleted
# predecessor task had accumulated for this sensor.
restored_notes = pop_stashed_notes(hass, entity_id)
if restored_notes:
task_data["notes"] = restored_notes
# Link the suggested spare part (validated against the target
# object's parts — an unknown id is silently dropped, same as the
# task-CRUD path).
if sel.get("part_id"):
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
links = sanitize_consumes_parts(
[{"part_id": sel["part_id"], "quantity": 1}],
set(entry.data.get(CONF_PARTS) or {}),
)
if links:
task_data["consumes_parts"] = links
await async_persist_task(hass, entry, task_data)
tasks_created += 1
except (ValueError, KeyError) as err:
errors.append({"entity_id": entity_id, "reason": str(err)})
# Roll back an object created in THIS iteration whose task failed —
# never leave an empty, task-less orphan object behind (and undo the
# count + device-reuse pointer so a later selection re-creates it).
if created_entry_id is not None:
objects_created -= 1
if device_id:
device_to_entry.pop(device_id, None)
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"total": len(object_entries(hass)),
}
if errors:
result["errors"] = errors
connection.send_result(msg["id"], result)
@@ -0,0 +1,101 @@
"""WebSocket commands for saved filter views (v2.24).
``views/list`` returns the shared named views (read — any user applies them);
``views/save`` upserts one and ``views/delete`` removes one (write — creating or
deleting a shared view is a content change). Views live on the global config
entry's options; see ``helpers/saved_views`` for the shape + sanitiser.
"""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, DOMAIN, MAX_ID_LENGTH, MAX_VIEW_NAME_LENGTH
from ..helpers.permissions import require_write
from ..helpers.saved_views import list_saved_views, remove_view, sanitize_view, upsert_view
from . import _get_global_entry
def _persist(hass: HomeAssistant, views: list[dict[str, Any]]) -> None:
"""Write the views list back to the global entry's options."""
global_entry = _get_global_entry(hass)
if global_entry is None:
raise LookupError("global_entry_missing")
options = dict(global_entry.options or global_entry.data)
options[CONF_SAVED_FILTER_VIEWS] = views
hass.config_entries.async_update_entry(global_entry, options=options)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/views/list"})
@websocket_api.async_response
async def ws_list_saved_views(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return every shared saved filter view."""
connection.send_result(msg["id"], {"views": list_saved_views(hass)})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/views/save",
# Omit view_id to create; include it to update in place. (Not "id" — that
# key is the WebSocket message id the framework owns.)
vol.Optional("view_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_VIEW_NAME_LENGTH)),
vol.Optional("filters"): dict,
}
)
@require_write
@websocket_api.async_response
async def ws_save_saved_view(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create or update a shared saved view, then return the full list."""
clean = sanitize_view(
{"id": msg.get("view_id"), "name": msg["name"], "filters": msg.get("filters", {})},
view_id=msg.get("view_id"),
)
if clean is None:
connection.send_error(msg["id"], "invalid_view", "A view needs a non-empty name")
return
try:
views, saved_id = upsert_view(list_saved_views(hass), clean)
_persist(hass, views)
except ValueError:
connection.send_error(msg["id"], "too_many_views", "The saved-views limit has been reached")
return
except LookupError:
connection.send_error(msg["id"], "not_found", "Global entry not found")
return
connection.send_result(msg["id"], {"views": views, "saved_id": saved_id})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/views/delete",
vol.Required("view_id"): vol.All(str, vol.Length(min=1, max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_delete_saved_view(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a shared saved view by id, then return the remaining list."""
views = remove_view(list_saved_views(hass), msg["view_id"])
try:
_persist(hass, views)
except LookupError:
connection.send_error(msg["id"], "not_found", "Global entry not found")
return
connection.send_result(msg["id"], {"views": views})
@@ -12,7 +12,9 @@ from ..const import (
CONF_TASKS,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_COST,
MAX_DATE_LENGTH,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_TEXT_LENGTH,
)
@@ -26,6 +28,30 @@ from . import (
# ---------------------------------------------------------------------------
def _load_task_context(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
*,
need_coordinator: bool = True,
) -> tuple[Any, Any] | None:
"""Resolve ``(runtime_data, entry)`` for a task action, or send the standard
not-found error and return None.
Consolidates the identical prologue the task-action handlers copied inline
(the copies had already drifted — e.g. snooze omitted the coordinator check).
"""
rd = _get_runtime_data(hass, msg["entry_id"])
if need_coordinator and (rd is None or rd.coordinator is None):
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return None
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
return None
return rd, entry
def _completion_blocked(rd: Any, task_id: str) -> bool:
"""True iff the task's completion window forbids completing it right now.
@@ -48,8 +74,8 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
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.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=1_000_000)), None),
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), 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),
# Restrict checklist_state to {string-key (≤500): bool, ...} with
# a hard cap on entries. Without this, attackers (or bad clients)
# could inflate the per-task history with arbitrarily large dicts.
@@ -67,6 +93,9 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
# 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),
# Spare parts: on an auto-created "buy" task, how many units were
# actually bought (dialog override of the part's restock_quantity).
vol.Optional("restock_quantity"): vol.Any(vol.All(int, vol.Range(min=1, max=9999)), None),
}
)
@websocket_api.async_response
@@ -76,15 +105,10 @@ async def ws_complete_task(
msg: dict[str, Any],
) -> None:
"""Mark a task as completed."""
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, _entry = ctx
if _completion_blocked(rd, msg["task_id"]):
connection.send_error(
@@ -103,6 +127,7 @@ async def ws_complete_task(
feedback=msg.get("feedback"),
photo_doc_id=msg.get("photo_doc_id"),
reading_value=msg.get("reading_value"),
restock_quantity=msg.get("restock_quantity"),
)
connection.send_result(msg["id"], {"success": True})
@@ -186,15 +211,10 @@ async def ws_skip_task(
msg: dict[str, Any],
) -> None:
"""Skip the current maintenance cycle."""
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, _entry = ctx
await rd.coordinator.skip_maintenance(
task_id=msg["task_id"],
@@ -221,15 +241,10 @@ async def ws_reset_task(
"""Reset the last performed date."""
from datetime import date as date_cls
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, _entry = ctx
reset_date = None
if msg.get("date"):
@@ -263,15 +278,10 @@ async def ws_postpone_task(
"""Postpone the current occurrence to a chosen date (per-occurrence defer)."""
from datetime import date as date_cls
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, _entry = ctx
try:
until = date_cls.fromisoformat(msg["until"])
@@ -304,9 +314,7 @@ async def ws_snooze_task(
"""
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
if _load_task_context(hass, connection, msg, need_coordinator=False) is None:
return
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
@@ -37,12 +37,14 @@ from ..const import (
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
MAX_URL_LENGTH,
NOTIFICATION_MANAGER_KEY,
HistoryEntryType,
)
from ..helpers.dates import INTERVAL_UNITS
from ..helpers.permissions import require_write
from ..helpers.schedule import (
FLAT_RECURRENCE_KEYS,
KIND_INTERVAL,
Schedule,
normalize_task_storage,
)
@@ -103,6 +105,8 @@ from .tasks_validation import (
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): 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
@@ -163,7 +167,21 @@ async def ws_create_task(
# Recurrence: an explicit nested `schedule` (calendar kinds) takes
# precedence; otherwise build from the flat v2.6.x fields.
if msg.get("schedule"):
task_data["schedule"] = Schedule.from_dict(msg["schedule"]).to_dict()
incoming = Schedule.from_dict(msg["schedule"]).to_dict()
task_data["schedule"] = incoming
# A BARE interval schedule (kind=interval, no `every`) is only the
# carrier for the season/ends extras the panel always sends for a
# time-based task — the real interval still rides the flat
# interval_days / interval_unit / interval_anchor fields. Carry them
# over so normalize_task_storage merges them onto the schedule; without
# this the interval was dropped entirely (#88 regression).
if incoming.get("kind") == KIND_INTERVAL and incoming.get("every") is None:
if msg.get("interval_days") is not None:
task_data["interval_days"] = msg["interval_days"]
if msg.get("interval_unit", "days") != "days":
task_data["interval_unit"] = msg["interval_unit"]
if msg.get("interval_anchor", "completion") != "completion":
task_data["interval_anchor"] = msg["interval_anchor"]
else:
task_data["schedule_type"] = msg.get("schedule_type", "time_based")
if msg.get("interval_days") is not None:
@@ -248,6 +266,15 @@ async def ws_create_task(
# 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
if msg.get("consumes_parts") is not None:
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
links = sanitize_consumes_parts(
msg["consumes_parts"], set(entry.data.get(CONF_PARTS) or {})
)
if links:
task_data["consumes_parts"] = links
if msg.get("checklist"):
task_data["checklist"] = msg["checklist"]
if msg.get("labels"):
@@ -276,13 +303,17 @@ async def ws_create_task(
connection.send_result(msg["id"], result)
return
await async_persist_task(
hass,
entry,
task_data,
last_performed=initial_last_performed,
history=initial_history,
)
try:
await async_persist_task(
hass,
entry,
task_data,
last_performed=initial_last_performed,
history=initial_history,
)
except ValueError as err:
connection.send_error(msg["id"], "limit_reached", str(err))
return
result = {"task_id": task_id}
if tc_warnings:
@@ -325,6 +356,8 @@ async def ws_create_task(
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): 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
@@ -441,6 +474,7 @@ async def ws_update_task(
"custom_icon": "custom_icon",
"nfc_tag_id": "nfc_tag_id",
"reading_unit": "reading_unit",
"consumes_parts": "consumes_parts",
"priority": "priority",
"checklist": "checklist",
"labels": "labels",
@@ -464,9 +498,20 @@ async def ws_update_task(
or msg.get("schedule_type") in FLAT_SCHEDULE_TYPES
)
if msg.get("schedule"):
for key in FLAT_RECURRENCE_KEYS:
task.pop(key, None)
task["schedule"] = Schedule.from_dict(msg["schedule"]).to_dict()
incoming = Schedule.from_dict(msg["schedule"]).to_dict()
task["schedule"] = incoming
# A COMPLETE nested schedule is authoritative — drop the flat recurrence
# keys so it wins. But a BARE interval schedule (kind=interval with no
# `every`) is only the carrier for the season/ends extras the panel
# always sends for a time-based task; the real interval still rides the
# flat interval_days / interval_unit / interval_anchor fields, which
# normalize_task_storage merges onto it. Popping them for the bare
# interval dropped the interval entirely (#88 regression from the
# season/ends work). Keep them in that one case.
bare_interval = incoming.get("kind") == KIND_INTERVAL and incoming.get("every") is None
if not bare_interval:
for key in FLAT_RECURRENCE_KEYS:
task.pop(key, None)
elif _flat_recurrence_edit:
task.pop("schedule", None)
elif "schedule_type" in msg:
@@ -567,6 +612,11 @@ async def async_delete_task(
return False
old_trigger_config = new_tasks[task_id].get("trigger_config")
# Adopted problem-sensor task? Preserve its notes for a later re-adopt
# (no-op for everything else).
from ..helpers.problem_sensors import stash_task_notes_for_readopt
stash_task_notes_for_readopt(hass, new_tasks[task_id])
del new_tasks[task_id]
new_data[CONF_TASKS] = new_tasks
@@ -585,7 +635,7 @@ async def async_delete_task(
await store.async_save()
# Clean up notification state for deleted task
nm = hass.data.get(DOMAIN, {}).get("_notification_manager")
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
if nm is not None:
nm.clear_task_state(entry.entry_id, task_id)
@@ -10,6 +10,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
MAX_COST,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_TEXT_LENGTH,
@@ -47,8 +49,8 @@ from . import (
# Patch fields — all optional; absent fields stay unchanged.
vol.Optional("timestamp"): vol.All(str, vol.Length(max=64)),
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=1_000_000)), None),
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), 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),
vol.Optional("completed_by"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
}
)
@@ -111,24 +111,24 @@ async def ws_unarchive_task(
if entry is None:
return
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
task_id = msg["task_id"]
if task_id not in tasks_data:
td = dict(entry.data.get(CONF_TASKS, {}).get(task_id, {}))
if not td:
connection.send_error(msg["id"], "not_found", "Task not found")
return
td = dict(tasks_data[task_id])
if td.get("archived_at") is None:
connection.send_error(msg["id"], "not_archived", "Task is not archived")
return
td.pop("archived_at", None)
td.pop("archived_reason", None)
# Fresh cycle for recurring tasks. last_performed is dynamic state → Store
# when present, else the static dict (legacy). One-off/manual: no re-anchor.
# The store flush is this handler's only await — the ConfigEntry mutation
# below re-reads AFTER it, so a concurrent writer landing during the disk
# write can't be reverted by a stale whole-map write (the migration-race
# class, bug audit 2026-07-11).
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
legacy_anchor = False
if _is_recurring_schedule(td):
today_iso = dt_util.now().date().isoformat()
if store is not None:
@@ -137,12 +137,24 @@ async def ws_unarchive_task(
state.pop("last_planned_due", None)
await store.async_save()
else:
td["last_performed"] = today_iso
td.pop("last_planned_due", None)
legacy_anchor = True
# Re-derive the task from a FRESH read and patch only its key.
fresh_tasks = entry.data.get(CONF_TASKS, {})
if task_id not in fresh_tasks:
connection.send_error(msg["id"], "not_found", "Task not found")
return
td = dict(fresh_tasks[task_id])
td.pop("archived_at", None)
td.pop("archived_reason", None)
if legacy_anchor:
td["last_performed"] = dt_util.now().date().isoformat()
td.pop("last_planned_due", None)
tasks_data[task_id] = td
new_data = dict(entry.data)
new_data[CONF_TASKS] = tasks_data
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = td
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await hass.config_entries.async_reload(entry.entry_id)
@@ -15,6 +15,7 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.schedule import (
normalize_task_storage,
@@ -46,6 +47,13 @@ async def async_persist_task(
# Store recurrence in the canonical nested `schedule` shape (schedule-model v2).
task_data = normalize_task_storage(task_data)
task_id = task_data["id"]
# Per-object task cap — this is the single create chokepoint for BOTH the
# task/create WS command and the add_task service, so one guard covers both.
# The ValueError surfaces as a WS error / a service ValidationError at the
# callers (a runaway automation can't inflate ConfigEntry.data without bound).
existing_tasks = entry.data.get(CONF_TASKS, {})
if task_id not in existing_tasks and len(existing_tasks) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"This object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task_data
@@ -77,31 +77,41 @@ async def ws_assign_user(
if entry is None:
return
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
task_id = msg["task_id"]
if task_id not in tasks_data:
if task_id not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
return
user_id = msg.get("user_id")
# Validate user exists if provided
# Validate user exists if provided — BEFORE touching entry.data. This is
# the handler's only await; reading the tasks after it means a concurrent
# writer landing during the auth lookup (e.g. a completing task persisting
# its rotation) can't be reverted by a stale whole-map write (the
# migration-race class, bug audit 2026-07-11).
if user_id:
user = await hass.auth.async_get_user(user_id)
if user is None:
connection.send_error(msg["id"], "invalid_user", "User not found")
return
tasks_data = entry.data.get(CONF_TASKS, {})
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
task = dict(tasks_data[task_id])
if user_id is None:
# Unassign user - remove field if it exists
task.pop("responsible_user_id", None)
else:
task["responsible_user_id"] = user_id
tasks_data[task_id] = task
# Patch only this task's key onto a fresh read — never write back a map
# snapshot from before an await.
new_data = dict(entry.data)
new_data[CONF_TASKS] = tasks_data
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
# Refresh coordinator