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