updated apps
This commit is contained in:
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user