189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -152,6 +152,8 @@ def _build_task_summary(
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": task_data.get("last_performed"),
"notes": task_data.get("notes"),
# #170: reference number within the object ("8.3" = object 8, task 3).
"ref_no": task_data.get("ref_no"),
"documentation_url": task_data.get("documentation_url"),
"custom_icon": task_data.get("custom_icon"),
"nfc_tag_id": task_data.get("nfc_tag_id"),
@@ -160,6 +162,8 @@ def _build_task_summary(
"allow_skip": task_data.get("allow_skip") is not False,
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
"reading_unit": task_data.get("reading_unit"),
# #161 phase 2: reading slots [{id, name, unit}] ([] = single value).
"readings": task_data.get("readings") or [],
# 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"),
@@ -372,6 +376,8 @@ def _build_object_response(
"documentation_url": obj_data.get("documentation_url"),
# v1.4.10 (#46): free-form notes shown below the meta block.
"notes": obj_data.get("notes"),
# #170: the object's reference number ("8").
"ref_no": obj_data.get("ref_no"),
# v2.10.0 archive: object-level archived state. `archived` bool +
# the raw timestamp so the panel can hide it by default and show
# "archived on …" in the Archived section.
@@ -557,6 +563,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_battery_fleet_history,
ws_battery_fleet_mark_replaced,
ws_battery_fleet_overview,
ws_battery_fleet_set_due_without_sensor,
ws_battery_fleet_set_excluded,
ws_battery_fleet_set_included,
ws_battery_fleet_set_track_self_charging,
@@ -580,6 +587,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_documents_search,
ws_documents_storage,
ws_documents_update,
ws_search,
)
from .groups import (
ws_create_group,
@@ -718,6 +726,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
websocket_api.async_register_command(hass, ws_battery_fleet_set_included)
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
websocket_api.async_register_command(hass, ws_battery_fleet_set_due_without_sensor)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
@@ -753,6 +762,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_documents_update)
websocket_api.async_register_command(hass, ws_documents_delete)
websocket_api.async_register_command(hass, ws_documents_search)
websocket_api.async_register_command(hass, ws_search)
def foreign_part_resolver(hass):
@@ -17,6 +17,7 @@ from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import (
async_compute_overview,
async_level_history,
fleet_due_without_sensor,
fleet_excluded_entities,
fleet_track_self_charging,
has_batteries,
@@ -30,6 +31,7 @@ from ..helpers.battery_fleet_setup import (
fleet_task_trigger_ok,
set_battery_excluded,
set_battery_included,
set_due_without_sensor,
set_track_self_charging,
)
from ..helpers.permissions import require_write
@@ -70,6 +72,9 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
"types": ov.types,
# #135 follow-up: the roster's track-self-charging toggle state.
"track_self_charging": fleet_track_self_charging(hass),
# D#162: whether a passed forecast on a sensorless note counts as
# due (the roster's second toggle; default on).
"due_without_sensor": fleet_due_without_sensor(hass),
# Manually excluded batteries (issue #107) — names enriched where
# the entity still exists, so the restore list stays readable.
"excluded": [
@@ -196,6 +201,24 @@ async def ws_battery_fleet_set_track_self_charging(
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/set_due_without_sensor",
vol.Required("enabled"): bool,
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_set_due_without_sensor(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Fleet-wide option: a passed forecast on a sensorless note is due (D#162)."""
if not set_due_without_sensor(hass, msg["enabled"]):
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
return
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/mark_replaced",
@@ -41,12 +41,15 @@ from ..const import (
CONF_DISABLED_TEMPLATE_IDS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_EVENT_ONLY,
CONF_NOTIFY_EXTRA_DATA,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SCOPE_VIEW_ID,
@@ -145,6 +148,8 @@ def _build_full_settings(
"operator_write_enabled": options.get(CONF_OPERATOR_WRITE_ENABLED, False),
# (#67): ordered objects-table columns for the panel All-Objects view.
"objects_table_columns": options.get(CONF_OBJECTS_TABLE_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS),
# #169 follow-up: per-member avatar overrides (initials / palette colour).
"member_display": options.get(CONF_MEMBER_DISPLAY, {}),
# v2.21: template-gallery curation (ids hidden from the pickers).
"disabled_template_ids": options.get(CONF_DISABLED_TEMPLATE_IDS, []),
# v2.10.0: archive automation thresholds (panel Settings → Archive).
@@ -205,6 +210,10 @@ def _build_full_settings(
# v2.26: notification routing — saved-view id scoping which
# tasks may notify ("" = all tasks).
"scope_view_id": options.get(CONF_NOTIFY_SCOPE_VIEW_ID, ""),
# #165: your own notification rule — event-only delivery and the
# extra-data template merged into every notify payload.
"event_only": options.get(CONF_NOTIFY_EVENT_ONLY, False),
"extra_data": options.get(CONF_NOTIFY_EXTRA_DATA, ""),
},
"actions": {
"complete_enabled": options.get(CONF_ACTION_COMPLETE_ENABLED, False),
@@ -716,6 +725,13 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
cols.append(v)
filtered[CONF_OBJECTS_TABLE_COLUMNS] = cols or list(DEFAULT_OBJECTS_TABLE_COLUMNS)
# #169 follow-up: member avatars — initials capped, colours from the
# palette only, empty entries dropped (an empty map clears every override).
if CONF_MEMBER_DISPLAY in filtered:
from ..helpers.member_display import sanitize_member_display
filtered[CONF_MEMBER_DISPLAY] = sanitize_member_display(filtered[CONF_MEMBER_DISPLAY])
# v2.21: disabled_template_ids — keep only ids of templates that actually
# exist (a typo/stale id must not linger invisibly), dedupe.
if CONF_DISABLED_TEMPLATE_IDS in filtered:
@@ -28,6 +28,7 @@ from ..const import (
MAX_URL_LENGTH,
)
from ..helpers.permissions import require_write
from ..helpers.search_match import query_tokens, score_fields, snippet
from . import _get_object_entries, _load_object_entry, object_id_for_entry
from .tasks import _is_safe_url
@@ -89,8 +90,18 @@ async def ws_documents_storage(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the global storage summary (physical vs logical, per object/category)."""
connection.send_result(msg["id"], _get_store(hass).storage_summary())
"""Return the global storage summary (physical vs logical, per object/category).
``search_index`` (#171) says how much of the library is full-text
searchable: ``indexed`` blobs carry a text layer, ``no_text`` are scans /
image-only PDFs, ``unsupported`` are photos and other binaries,
``pending`` still wait for the backfill.
"""
store = _get_store(hass)
summary = store.storage_summary()
if store.text_index is not None:
summary["search_index"] = store.text_index.summary()
connection.send_result(msg["id"], summary)
@websocket_api.websocket_command(
@@ -193,8 +204,42 @@ async def ws_documents_delete(
connection.send_result(msg["id"], {"success": True, "bytes_freed": freed})
_SEARCH_FIELDS = ("title", "filename", "url", "mime")
_SEARCH_MAX_RESULTS = 50
#: Field weights for the tolerant matcher — the title is what people remember,
#: the file name and tags come next, a URL or MIME rarely.
_DOC_FIELD_WEIGHTS = (("title", 3), ("filename", 2), ("url", 1), ("mime", 1))
def _object_map(hass: HomeAssistant) -> dict[str, tuple[str, str]]:
"""object id -> (entry_id, name), so hits carry a human-readable location."""
obj_map: dict[str, tuple[str, str]] = {}
for entry in _get_object_entries(hass):
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if isinstance(oid, str) and oid:
obj_map[oid] = (entry.entry_id, obj.get("name", ""))
return obj_map
def _doc_hit(did: str, doc: dict[str, Any], obj_map: dict[str, tuple[str, str]]) -> dict[str, Any]:
entry_id, name = obj_map.get(doc.get("object_id", ""), ("", ""))
return {
"id": did,
"entry_id": entry_id,
"object_name": name,
"kind": doc.get("kind"),
"title": doc.get("title"),
"filename": doc.get("filename"),
"url": doc.get("url"),
"size": doc.get("size"),
"tags": doc.get("tags") or [],
}
def _doc_meta_score(tokens: list[tuple[str, ...]], doc: dict[str, Any]) -> int:
fields: list[tuple[str, int]] = [(str(doc.get(f) or ""), w) for f, w in _DOC_FIELD_WEIGHTS]
fields.extend((str(tag), 2) for tag in (doc.get("tags") or []))
return score_fields(tokens, fields)
@websocket_api.websocket_command(
@@ -209,40 +254,155 @@ async def ws_documents_search(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Find documents across all objects by title / filename / tag (read, open)."""
query = msg["query"].strip().lower()
if not query:
"""Find documents across all objects by title / filename / tag (read, open).
Tolerant matching (#171): every word of the query must match somewhere —
as a prefix, a substring or with one typo, diacritics folded — and hits
come back best first.
"""
tokens = query_tokens(msg["query"])
if not tokens:
connection.send_result(msg["id"], {"results": []})
return
# object id -> (entry_id, name), so hits carry a human-readable location.
obj_map: dict[str, tuple[str, str]] = {}
for entry in _get_object_entries(hass):
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if isinstance(oid, str) and oid:
obj_map[oid] = (entry.entry_id, obj.get("name", ""))
obj_map = _object_map(hass)
store = _get_store(hass)
results: list[dict[str, Any]] = []
scored: list[tuple[int, str, dict[str, Any]]] = []
for did, doc in store.documents.items():
haystack = " ".join([str(doc.get(f) or "") for f in _SEARCH_FIELDS] + list(doc.get("tags") or [])).lower()
if query not in haystack:
continue
entry_id, name = obj_map.get(doc.get("object_id", ""), ("", ""))
results.append(
{
"id": did,
"entry_id": entry_id,
"object_name": name,
"kind": doc.get("kind"),
"title": doc.get("title"),
"filename": doc.get("filename"),
"url": doc.get("url"),
"size": doc.get("size"),
"tags": doc.get("tags") or [],
}
)
if len(results) >= _SEARCH_MAX_RESULTS:
break
score = _doc_meta_score(tokens, doc)
if score > 0:
scored.append((score, did, doc))
scored.sort(key=lambda item: -item[0])
results = [_doc_hit(did, doc, obj_map) for _, did, doc in scored[:_SEARCH_MAX_RESULTS]]
connection.send_result(msg["id"], {"results": results})
_GLOBAL_SEARCH_MAX = 20
_HISTORY_NOTE_MAX = 4000
def _history_hit(entry_id: str, task_id: str, td: dict[str, Any], object_name: str, h: dict[str, Any], *, score: int) -> dict[str, Any]:
"""One history hit of the global search; ``ref`` is the completion's
reference ("8.3-2", #170) when the object, task and entry all carry one."""
from ..helpers.reference_numbers import format_entry_ref
obj_ref = td.get("_object_ref")
return {
"entry_id": entry_id,
"task_id": task_id,
"task_name": str(td.get("name") or ""),
"object_name": object_name,
"timestamp": h.get("timestamp"),
"type": h.get("type"),
"ref": format_entry_ref(obj_ref, td.get("ref_no"), h.get("ref_no")),
"snippet": "",
"score": score,
}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/search",
vol.Required("query"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)),
vol.Optional("limit", default=8): vol.All(int, vol.Range(min=1, max=_GLOBAL_SEARCH_MAX)),
}
)
@websocket_api.async_response
async def ws_search(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""The server side of the panel's global search (#171, read, open).
Objects, tasks and parts are matched in the panel from data it already
holds; this command covers what is NOT in the browser: documents (by
title / file name / tags AND by their extracted text — page + snippet)
and the notes written into task histories. Both lists come back best
first, ``limit`` each.
"""
from ..helpers.aggregate import merged_tasks, object_name
from ..helpers.reference_numbers import parse_ref
query = msg["query"]
limit = max(1, min(int(msg.get("limit", 8)), _GLOBAL_SEARCH_MAX))
# A typed reference ("8.3-2", #170) names ONE completion — answer with
# exactly that entry; objects and tasks by reference the panel resolves
# from its own data.
parsed = parse_ref(query)
if parsed is not None and parsed[2] is not None:
obj_ref, task_ref, entry_ref = parsed
for entry in _get_object_entries(hass):
if (entry.data.get(CONF_OBJECT) or {}).get("ref_no") != obj_ref:
continue
for tid, td in merged_tasks(entry).items():
if td.get("ref_no") != task_ref:
continue
for h in td.get("history") or []:
if isinstance(h, dict) and h.get("ref_no") == entry_ref:
hit = _history_hit(entry.entry_id, tid, {**td, "_object_ref": obj_ref}, object_name(entry), h, score=1000)
hit["snippet"] = " ".join(str(h.get("notes") or "").split())[:160]
connection.send_result(msg["id"], {"documents": [], "history": [hit]})
return
connection.send_result(msg["id"], {"documents": [], "history": []})
return
tokens = query_tokens(query)
if not tokens:
connection.send_result(msg["id"], {"documents": [], "history": []})
return
obj_map = _object_map(hass)
store = _get_store(hass)
# Documents: metadata hits first, then content hits from the text index
# (a document that matches both keeps the higher score and gains the page).
doc_hits: dict[str, dict[str, Any]] = {}
for did, doc in store.documents.items():
score = _doc_meta_score(tokens, doc)
if score > 0:
doc_hits[did] = {**_doc_hit(did, doc, obj_map), "score": score * 10, "match": "meta", "page": None, "snippet": ""}
if store.text_index is not None:
by_digest: dict[str, list[str]] = {}
for did, doc in store.documents.items():
digest = doc.get("hash")
if isinstance(digest, str) and digest:
by_digest.setdefault(digest, []).append(did)
for hit in await store.text_index.async_search(msg["query"], limit=limit * 2):
for did in by_digest.get(hit["digest"], []):
cur = doc_hits.get(did)
if cur is None:
doc_hits[did] = {
**_doc_hit(did, store.documents[did], obj_map),
"score": hit["score"],
"match": "content",
"page": hit["page"],
"snippet": hit["snippet"],
}
else:
cur["page"] = hit["page"]
cur["snippet"] = hit["snippet"]
cur["score"] = max(cur["score"], hit["score"]) + min(cur["score"], hit["score"]) // 4
documents = sorted(doc_hits.values(), key=lambda d: -d["score"])[:limit]
# History notes across every task of every object.
history: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
oname = object_name(entry)
obj_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
for tid, td in merged_tasks(entry).items():
td = {**td, "_object_ref": obj_ref}
for h in td.get("history") or []:
notes = h.get("notes") if isinstance(h, dict) else None
if not isinstance(notes, str) or not notes.strip():
continue
score = score_fields(tokens, [(notes[:_HISTORY_NOTE_MAX], 1)])
if score <= 0:
continue
piece, _at = snippet(notes, tokens[0][0], radius=60)
hit = _history_hit(entry.entry_id, tid, td, oname, h, score=score)
hit["snippet"] = piece or " ".join(notes.split())[:160]
history.append(hit)
# Best first; equal scores newest first (two stable sorts).
history.sort(key=lambda h: str(h.get("timestamp") or ""), reverse=True)
history.sort(key=lambda h: -h["score"])
connection.send_result(msg["id"], {"documents": documents, "history": history[:limit]})
@@ -14,9 +14,11 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import (
BATTERY_FLEET_DUE_WITHOUT_SENSOR,
BATTERY_FLEET_EXCLUDED,
BATTERY_FLEET_INCLUDED,
BATTERY_FLEET_OBJECT_FLAG,
BATTERY_FLEET_REMOVED_PARTS,
BATTERY_FLEET_TASK_FLAG,
BATTERY_FLEET_TRACK_SELF_CHARGING,
CONF_OBJECT,
@@ -45,6 +47,11 @@ from . import _get_object_entries, _load_object_entry
_LOGGER = logging.getLogger(__name__)
def _ref_or_none(value: Any) -> int | None:
"""#170: a reference number / counter from a backup — positive int or nothing."""
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
def _iso_marker(value: Any) -> str | None:
"""Keep ``value`` only if it parses as an ISO date/datetime, else drop it.
@@ -90,10 +97,68 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
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)
# Readings (#83 / #161 phase 2): same NaN/Infinity hole — a poisoned
# value would break every delta after it. Malformed slot snapshots
# are dropped item-wise, the completion itself is kept.
rv = clean.get("reading_value")
if rv is not None and (isinstance(rv, bool) or not isinstance(rv, (int, float)) or not math.isfinite(rv)):
clean.pop("reading_value", None)
if "reading_values" in clean:
from ..helpers.reading_slots import history_reading_values
snapshot = history_reading_values(clean)
if snapshot:
clean["reading_values"] = snapshot
else:
clean.pop("reading_values", None)
out.append(clean)
return out
def _remap_document_refs(
import_tasks: dict[str, dict[str, Any]],
import_parts: dict[str, dict[str, Any]],
doc_id_map: dict[str, str],
) -> None:
"""Re-point document references at the freshly minted doc ids.
History entries carry completion photos (``photo_doc_ids``, or the
pre-2.75 ``photo_doc_id`` scalar — folded into the list here) and spare
parts carry a ``doc_id``. Ids the export did not carry (a hand-written
file, a doc that vanished before the export) stay verbatim: a dangling
reference renders as a missing picture, which is what it is.
"""
from ..helpers.completion_photos import history_photo_ids
for task_data in import_tasks.values():
for hist_entry in task_data.get("history") or []:
if not isinstance(hist_entry, dict):
continue
photos = history_photo_ids(hist_entry)
if not photos:
continue
hist_entry.pop("photo_doc_id", None)
hist_entry["photo_doc_ids"] = [doc_id_map.get(p, p) for p in photos]
for part in import_parts.values():
old = part.get("doc_id")
if isinstance(old, str) and old in doc_id_map:
part["doc_id"] = doc_id_map[old]
async def _drop_imported_documents(doc_store: Any, object_id: str) -> None:
"""Undo a pre-flow document import when the object never came to be.
Documents are recreated BEFORE the entry flow (their fresh ids must be
known to remap history photos and part doc_ids); if the flow then
fails they would linger as orphans nobody can reach. ``object_id`` is
freshly minted per import, so every doc under it is ours to drop.
"""
if doc_store is None:
return
for orphan in list(doc_store.for_object(object_id)):
await doc_store.async_remove(orphan["id"])
def _import_fleet_identity(
hass: HomeAssistant,
obj_data: dict[str, Any],
@@ -132,6 +197,15 @@ def _import_fleet_identity(
import_obj[key] = cleaned[:FLEET_LIST_CAP]
if obj_data.get(BATTERY_FLEET_TRACK_SELF_CHARGING) is True:
import_obj[BATTERY_FLEET_TRACK_SELF_CHARGING] = True
if obj_data.get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is False:
import_obj[BATTERY_FLEET_DUE_WITHOUT_SENSOR] = False
# Deleted type-parts stay deleted after a restore too — same id rule as
# _keep_fleet_part_id, so nothing but ``batt_<type>`` ids get through.
raw_removed = obj_data.get(BATTERY_FLEET_REMOVED_PARTS)
if isinstance(raw_removed, list):
removed = sorted({p.strip() for p in raw_removed if isinstance(p, str) and _keep_fleet_part_id(True, p.strip())})
if removed:
import_obj[BATTERY_FLEET_REMOVED_PARTS] = removed[:FLEET_LIST_CAP]
return True
@@ -617,6 +691,10 @@ async def ws_import_json(
# semantics as paused_at, so it gets the same ISO validation. Its
# tasks carry their own archived_* pair (mirrored below).
"archived_at": _iso_marker(obj_data.get("archived_at")),
# #170: keep the numbers a backup carries (collisions are
# renumbered by the setup pass); bool/negative junk is dropped.
"ref_no": _ref_or_none(obj_data.get("ref_no")),
"next_task_ref": _ref_or_none(obj_data.get("next_task_ref")),
"task_ids": [],
}
@@ -668,6 +746,9 @@ async def ws_import_json(
# remapped onto the freshly generated tasks (mirrors part_id_map).
task_id_map: dict[str, str] = {}
fleet_task_seen = False
# Per-task import losses (an invalid trigger is dropped, not fatal) —
# reported next to the NFC warnings instead of vanishing silently.
task_warnings: list[str] = []
tasks_list = obj_entry.get("tasks", [])
if not isinstance(tasks_list, list):
tasks_list = []
@@ -726,12 +807,14 @@ async def ws_import_json(
"priority",
"labels",
"earliest_completion_days",
"ref_no",
"on_complete_action",
"quick_complete_defaults",
"assignee_pool",
"required_completion_fields",
"rotation_strategy",
"reading_unit",
"readings",
# spare parts (ids remapped below)
"consumes_parts",
"part_ref",
@@ -878,6 +961,16 @@ async def ws_import_json(
cleaned = [c for c in cleaned if c]
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
# #161 phase 2: reading slots — same shape rules as the WS write.
if task_data.get("readings") is not None:
from ..helpers.reading_slots import sanitize_reading_slots
slots = sanitize_reading_slots(task_data["readings"])
if slots:
task_data["readings"] = slots
else:
task_data.pop("readings", None)
# schedule_time: strict HH:MM, otherwise drop
st = task_data.get("schedule_time")
if st is not None:
@@ -889,11 +982,23 @@ async def ws_import_json(
# entirely if invalid — so import isn't a hole around trigger validation.
tc = task_data.get("trigger_config")
if isinstance(tc, dict):
errors, _warnings = _validate_trigger_config(hass, tc)
if errors:
# The export carries the live per-entity trigger state
# (accumulated runtime hours, counter baseline, change count)
# merged in as ``_trigger_state``. The validator strips it as
# an unknown key, so a restore silently started every
# sensor trigger from zero (bug review 2026-09-04). Keep it
# aside and re-attach it: the fresh entry's first setup
# migrates it into the Store like any other dynamic field.
trigger_state = tc.pop("_trigger_state", None)
tc_errors, _warnings = _validate_trigger_config(hass, tc)
if tc_errors:
task_data.pop("trigger_config", None)
task_warnings.append(f"{task_name}: trigger dropped — {tc_errors[0]}")
elif isinstance(trigger_state, dict) and trigger_state:
tc["_trigger_state"] = trigger_state
elif tc is not None:
task_data.pop("trigger_config", None)
task_warnings.append(f"{task_name}: trigger dropped — not a mapping")
import_tasks[task_id] = task_data
import_obj["task_ids"].append(task_id)
@@ -907,6 +1012,27 @@ async def ws_import_json(
if nfc_warn:
nfc_warnings.append(nfc_warn)
# (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).
# Done BEFORE the entry is created: the docs get fresh ids, and the
# history entries (completion photos, #161) and spare parts (doc_id)
# that point at them by id must be re-pointed before they are
# persisted — the export carries the old ids for exactly this.
doc_store = None
import_docs = obj_entry.get("documents")
if isinstance(import_docs, list) and import_docs:
from .. import DOCUMENT_STORE_KEY
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None:
doc_id_map: dict[str, str] = {}
await doc_store.async_import_documents(
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map, id_map=doc_id_map
)
if doc_id_map:
_remap_document_refs(import_tasks, import_parts, doc_id_map)
try:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -920,6 +1046,7 @@ async def ws_import_json(
except Exception:
_LOGGER.exception("JSON import failed for %s", obj_name)
errors.append({"name": obj_name, "reason": "unexpected error"})
await _drop_imported_documents(doc_store, obj_id)
continue
if result["type"] == "create_entry":
entry_info: dict[str, Any] = {
@@ -927,8 +1054,10 @@ async def ws_import_json(
"name": obj_name,
"task_count": len(import_tasks),
}
if nfc_warnings:
entry_info["warnings"] = nfc_warnings
if nfc_warnings or task_warnings:
entry_info["warnings"] = nfc_warnings + task_warnings
for warning in task_warnings:
_LOGGER.warning("JSON import of %s: %s", obj_name, warning)
created.append(entry_info)
# Restore tracked part stocks into the new entry's Store.
@@ -948,21 +1077,9 @@ async def ws_import_json(
if new_entry is not None:
schedule_buy_task_reconcile(hass, new_entry)
# (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).
import_docs = obj_entry.get("documents")
if isinstance(import_docs, list) and import_docs:
from .. import DOCUMENT_STORE_KEY
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, task_id_map=task_id_map, part_id_map=part_id_map
)
else:
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
await _drop_imported_documents(doc_store, obj_id)
resp: dict[str, Any] = {
"imported": created,
@@ -35,7 +35,7 @@ from ..const import (
)
from ..helpers.pause import reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields, strip_task_runtime_state
from ..helpers.sanitize import cap_object_fields, strip_object_reference, strip_task_runtime_state
from . import (
_build_object_response,
_get_object_entries,
@@ -490,6 +490,7 @@ async def ws_duplicate_object(
new_obj[CONF_OBJECT_SERIAL_NUMBER] = None
new_obj["task_ids"] = []
new_obj.pop("archived_at", None)
strip_object_reference(new_obj)
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
@@ -874,6 +875,7 @@ async def ws_replace_object(
for key in ("archived_at", "paused_at", "paused_until", "replaced_by_entry_id"):
new_obj.pop(key, None)
new_obj["predecessor_entry_id"] = entry.entry_id
strip_object_reference(new_obj)
# Carry the parts shelf — the spares don't change when the machine dies.
# Fresh ids (like tasks); consumption links are remapped below and the
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_PARTS, MAX_ID_LENGTH
from ..const import BATTERY_FLEET_OBJECT_FLAG, BATTERY_FLEET_REMOVED_PARTS, CONF_OBJECT, CONF_PARTS, MAX_ID_LENGTH
from ..helpers.aggregate import object_name
from ..helpers.parts import (
MAX_PART_STOCK,
@@ -189,6 +189,14 @@ async def ws_delete_part(
td.pop(CONF_TASK_CONSUMES_PARTS, None)
tasks[tid] = td
new_data[CONF_TASKS] = tasks
# A deleted fleet type-part must STAY deleted: the start-up reconcile
# re-minted ``batt_<type>`` on every boot while the type was still in
# the fleet (bug review 2026-09-04). Tombstone it on the fleet object.
obj = new_data.get(CONF_OBJECT) or {}
if obj.get(BATTERY_FLEET_OBJECT_FLAG) is True and msg["part_id"].startswith("batt_"):
obj = dict(obj)
obj[BATTERY_FLEET_REMOVED_PARTS] = sorted(set(obj.get(BATTERY_FLEET_REMOVED_PARTS) or []) | {msg["part_id"]})
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
rd = _get_runtime_data(hass, entry.entry_id)
@@ -196,13 +204,16 @@ async def ws_delete_part(
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).
# Remove the part's stock sensor from the entity registry. Match from
# the END: fleet part ids are ``batt_<type>`` and ``_part_batt_aa`` is a
# prefix of ``_part_batt_aaa`` — the contained-segment match wiped the
# AAA sensor's registry entry along with the AA part (bug review
# 2026-09-04).
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:
if ent_entry.unique_id and ent_entry.unique_id.endswith(f"_part_{msg['part_id']}"):
ent_reg.async_remove(ent_entry.entity_id)
# The reconcile removes an open buy task for the now-gone part (it reloads
@@ -21,6 +21,7 @@ from ..const import (
MAX_ID_LENGTH,
MAX_TEXT_LENGTH,
)
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
from ..models.maintenance_task import MaintenanceTask
from . import (
_get_runtime_data,
@@ -97,12 +98,24 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
# QR deep-link fallback: the panel asserts the scan on a tag-gated task
# whose quick-complete needs the full dialog (bug audit 2026-08-29).
vol.Optional("via_tag_scan"): bool,
# Optional completion photo: the doc_id of an already-uploaded image
# (via the document upload endpoint, tagged "photo").
# Optional completion photos (#161): doc_ids of already-uploaded
# images (via the document upload endpoint, tagged "photo"). The
# scalar form is what pre-2.75 clients send — merged into the list.
vol.Optional("photo_doc_ids"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
None,
),
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
# 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),
# #161 phase 2: {slot_id: value} for a task with reading slots; None
# skips a meter this time. Ids are checked against the task's slots
# in the handler (unknown -> invalid_input).
vol.Optional("reading_values"): vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
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(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=9999)), None),
@@ -186,6 +199,24 @@ async def ws_complete_task(
foreign_part_ids=foreign_part_resolver(hass),
)
# #161 phase 2: resolve the slot values into the history snapshot.
reading_values = None
if msg.get("reading_values"):
from ..helpers.reading_slots import resolve_reading_values
from . import _get_merged_tasks
slot_task = _get_merged_tasks(_entry).get(msg["task_id"]) or {}
try:
reading_values = (
resolve_reading_values(
slot_task.get("readings") or [], msg["reading_values"], default_unit=slot_task.get("reading_unit")
)
or None
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
try:
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
@@ -194,8 +225,9 @@ async def ws_complete_task(
duration=msg.get("duration"),
checklist_state=msg.get("checklist_state"),
feedback=msg.get("feedback"),
photo_doc_id=msg.get("photo_doc_id"),
photo_doc_ids=normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id")) or None,
reading_value=msg.get("reading_value"),
reading_values=reading_values,
restock_quantity=msg.get("restock_quantity"),
used_parts=used_parts,
completed_at=completed_at,
@@ -320,8 +352,9 @@ async def ws_skip_task(
as_missed=msg.get("as_missed", False),
)
except ServiceValidationError as err:
# #150: the task carries a skip lock.
connection.send_error(msg["id"], "skip_disabled", str(err))
# #150: the task carries a skip lock; an inactive task keeps its own
# key (task_inactive_skip) so the panel can say why.
connection.send_error(msg["id"], err.translation_key or "skip_disabled", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -106,6 +106,7 @@ TASK_UPDATE_FIELD_MAP = {
"require_tag_scan": "require_tag_scan",
"allow_skip": "allow_skip",
"reading_unit": "reading_unit",
"readings": "readings",
"consumes_parts": "consumes_parts",
"phases": "phases",
"phase_sequence": "phase_sequence",
@@ -201,6 +202,9 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Optional("allow_skip"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
vol.Optional("readings"): vol.Any(list, None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
@@ -382,6 +386,12 @@ 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("readings"):
from ..helpers.reading_slots import sanitize_reading_slots
slots = sanitize_reading_slots(msg["readings"])
if slots:
task_data["readings"] = slots
if msg.get("consumes_parts") is not None:
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
@@ -484,6 +494,9 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Optional("allow_skip"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
vol.Optional("readings"): vol.Any(list, None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
@@ -616,6 +629,17 @@ async def ws_update_task(
foreign_part_ids=foreign_part_resolver(hass),
)
# #161 phase 2: same raw-field-map lesson — validate the slot list here so
# both write paths persist the identical shape; an empty list clears.
if "readings" in msg:
from ..helpers.reading_slots import sanitize_reading_slots
slots = sanitize_reading_slots(msg["readings"])
if slots:
task["readings"] = slots
else:
task.pop("readings", None)
# Phases (#139): the raw field-map copy above skipped validation (the
# raw-field-map lesson) — sanitize here, and clamp the Store cursor
# against the possibly shortened sequence (the Store wins on restore, so
@@ -17,6 +17,11 @@ from ..const import (
MAX_META_LENGTH,
MAX_TEXT_LENGTH,
)
from ..helpers.completion_photos import (
MAX_COMPLETION_PHOTOS,
history_photo_ids,
normalize_photo_doc_ids,
)
from ..helpers.permissions import require_write
from . import (
_get_runtime_data,
@@ -30,8 +35,9 @@ from . import (
# browser between read and write — timestamp is more stable. If multiple
# entries share a timestamp (rare), the first match is patched.
#
# Patchable fields: timestamp, notes, cost, duration, completed_by and — since
# #130 — used_parts (stock reconciled by the per-part delta). Anything else
# Patchable fields: timestamp, notes, cost, duration, completed_by, since
# #130 used_parts (stock reconciled by the per-part delta) and since #161
# photo_doc_ids (add/remove completion photos after the fact). Anything else
# (type, trigger_value, checklist_state, feedback) is intentionally read-only —
# those carry semantic meaning that shouldn't be silently rewritten.
#
@@ -53,6 +59,15 @@ from . import (
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),
# Recorded readings (#161 phase 2): a typo among nine meter values is
# likely, and nothing downstream depends on the entry being frozen.
# The scalar patches like the other fields; the slot map REPLACES the
# snapshot (None value = that meter unread), ids validated in the handler.
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
vol.Optional("reading_values"): vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
),
# #130: edit the entry's part consumption. The stock is reconciled by
# the per-part DELTA against the entry's previous used_parts; None (or
# []) clears the consumption and returns the old quantities to stock.
@@ -71,6 +86,14 @@ from . import (
),
None,
),
# #161: the entry's completion photos. Replaces the whole list; None
# (or []) detaches every photo from the entry. The documents
# themselves are never deleted here — they stay in the object's
# documents, the entry merely stops pointing at them.
vol.Optional("photo_doc_ids"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
None,
),
}
)
@require_write
@@ -137,9 +160,20 @@ async def ws_update_history_entry(
patched = dict(history[target_index])
# #161 phase 2: "never both" — the scalar cannot be set on an entry that
# carries a slot snapshot, nor on a task that records slots.
if msg.get("reading_value") is not None:
from . import _get_merged_tasks
if patched.get("reading_values") or (_get_merged_tasks(entry).get(task_id) or {}).get("readings"):
connection.send_error(
msg["id"], "invalid_input", "This entry records named readings — patch reading_values instead"
)
return
# Apply patch — explicit None means "clear field" (drop the key entirely
# so the dict stays minimal); explicit value sets it.
PATCHABLE = ("timestamp", "notes", "cost", "duration", "completed_by")
PATCHABLE = ("timestamp", "notes", "cost", "duration", "completed_by", "reading_value")
for field in PATCHABLE:
if field not in msg:
continue
@@ -149,6 +183,31 @@ async def ws_update_history_entry(
else:
patched[field] = value
# #161 phase 2: the slot snapshot. Ids resolve against the task's current
# slots OR the entry's own snapshot (a slot deleted since the completion
# stays editable); anything else is refused rather than guessed.
if "reading_values" in msg:
from ..helpers.reading_slots import history_reading_values, resolve_reading_values
from . import _get_merged_tasks
slot_task = _get_merged_tasks(entry).get(task_id) or {}
try:
new_values = resolve_reading_values(
slot_task.get("readings") or [],
msg["reading_values"],
keep=history_reading_values(patched),
default_unit=slot_task.get("reading_unit"),
)
except ValueError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
if new_values:
patched["reading_values"] = new_values
# The snapshot supersedes a scalar from the pre-slot era.
patched.pop("reading_value", None)
else:
patched.pop("reading_values", None)
# #130: part consumption on the entry. The stock is adjusted by the
# per-part delta between the stored and the submitted selection, so
# corrections and backfills keep the shelf honest. Best-effort like the
@@ -171,6 +230,23 @@ async def ws_update_history_entry(
else:
patched.pop("used_parts", None)
# #161: completion photos on the entry. A new id is linked to the task
# like a live completion does (best-effort); a removed id keeps its
# document and its links — only the entry forgets it. The legacy
# scalar is folded into the list the moment the entry is edited.
if "photo_doc_ids" in msg:
old_photos = history_photo_ids(patched)
new_photos = normalize_photo_doc_ids(msg["photo_doc_ids"])
patched.pop("photo_doc_id", None)
if new_photos:
patched["photo_doc_ids"] = new_photos
else:
patched.pop("photo_doc_ids", None)
if rd and rd.coordinator:
for doc_id in new_photos:
if doc_id not in old_photos:
await rd.coordinator._link_completion_photo(doc_id, task_id)
history[target_index] = patched
store.set_history(task_id, history)
@@ -42,13 +42,19 @@ async def ws_list_users(
# just id + name (needed for the assignee selector and name display) so they
# cannot enumerate who is an admin/owner via this command.
caller_is_admin = connection.user is not None and connection.user.is_admin
# #169 follow-up: every caller gets the avatar (initials + colour) — the
# resolved value, i.e. the admin override or the name/id-derived default.
from ..helpers.global_options import get_global_options
from ..helpers.member_display import member_display
options = get_global_options(hass)
for user in await hass.auth.async_get_users():
# Filter out system users and inactive users
if not user.is_active or user.system_generated:
continue
entry: dict[str, Any] = {"id": user.id, "name": user.name}
entry: dict[str, Any] = {"id": user.id, "name": user.name, **member_display(options, user.id, user.name)}
if caller_is_admin:
entry["is_admin"] = user.is_admin
entry["is_owner"] = user.is_owner
@@ -174,6 +174,10 @@ async def ws_vacation_preview(
"last_performed": task_data.get("last_performed"),
"created_at": task_data.get("created_at"),
"enabled": task_data.get("enabled", True),
# The preview's Skip button must follow the task's own
# rule — the row had no field and offered it to every
# time-based task (bug review 2026-09-04).
"allow_skip": task_data.get("allow_skip") is not False,
}
)