429 files
This commit is contained in:
1 parent
472c19bceb
commit
963cce81a7
566 files changed
+50311
-47616
No files matched your search
@@ -155,6 +155,7 @@ def _build_task_summary(
|
||||
"documentation_url": task_data.get("documentation_url"),
|
||||
"custom_icon": task_data.get("custom_icon"),
|
||||
"nfc_tag_id": task_data.get("nfc_tag_id"),
|
||||
"require_tag_scan": bool(task_data.get("require_tag_scan", False)),
|
||||
# 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
|
||||
@@ -261,6 +262,9 @@ def _build_task_summary(
|
||||
"degradation_rate": ct.get("_degradation_rate"),
|
||||
"degradation_trend": ct.get("_degradation_trend"),
|
||||
"degradation_r_squared": ct.get("_degradation_r_squared"),
|
||||
# How many prior service cycles the rate learned from (sawtooth
|
||||
# sensors — ET-4800 finding). 0 = current window only.
|
||||
"prediction_cycles": ct.get("_prediction_cycles", 0),
|
||||
"days_until_threshold": ct.get("_days_until_threshold"),
|
||||
"threshold_prediction_date": ct.get("_threshold_prediction_date"),
|
||||
"threshold_prediction_confidence": ct.get("_threshold_prediction_confidence"),
|
||||
|
||||
@@ -33,7 +33,9 @@ async def _persist_adaptive_config(
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store.set_adaptive_config(task_id, adaptive_config)
|
||||
store.async_delay_save()
|
||||
# Immediate save like every other user-initiated write: a debounced
|
||||
# 60 s timer would lose the change on a restart/reload in that window.
|
||||
await store.async_save()
|
||||
else:
|
||||
# Legacy: write to ConfigEntry.data
|
||||
static_tasks = dict(entry.data.get(CONF_TASKS, {}))
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
@@ -59,6 +59,7 @@ from ..const import (
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_REMINDER_LEAD_DAYS,
|
||||
CONF_SHOPPING_LIST_ENTITY,
|
||||
CONF_SNOOZE_DURATION_HOURS,
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
CONF_WARRANTY_REMINDER_ENABLED,
|
||||
@@ -149,6 +150,8 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
|
||||
"default_warning_days": options.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
|
||||
"notifications_enabled": options.get(CONF_NOTIFICATIONS_ENABLED, False),
|
||||
"notify_service": options.get(CONF_NOTIFY_SERVICE, ""),
|
||||
# v2.67: buy-task shopping sync target ("" = off).
|
||||
"shopping_list_entity": options.get(CONF_SHOPPING_LIST_ENTITY, ""),
|
||||
# Shared pickable-target list so the panel picker can't drift from
|
||||
# the options-flow dropdown (both go through build_notify_targets).
|
||||
"notify_targets": notify_targets or [],
|
||||
@@ -327,7 +330,7 @@ async def ws_subscribe(
|
||||
|
||||
deltas: bool = msg.get("deltas", False)
|
||||
compact: bool = msg.get("compact", False)
|
||||
attached_entry_ids: set[str] = set()
|
||||
attached_entry_ids: dict[str, tuple[Any, Callable[[], None]]] = {}
|
||||
unsub_callbacks: list[Callable[[], None]] = []
|
||||
dirty: set[str] = set()
|
||||
last_hash: dict[str, int] = {}
|
||||
@@ -405,18 +408,33 @@ async def ws_subscribe(
|
||||
debounce_unsub = async_call_later(hass, 1.0, _send_now)
|
||||
|
||||
def _attach_entry(entry_id: str) -> None:
|
||||
"""Attach a coordinator listener for a specific entry."""
|
||||
if entry_id in attached_entry_ids:
|
||||
return
|
||||
"""Attach a coordinator listener for a specific entry.
|
||||
|
||||
Every entry RELOAD (task create/edit/delete, options flow, ...) builds
|
||||
a brand-new coordinator and re-announces the entry via
|
||||
SIGNAL_NEW_OBJECT_ENTRY. An "already attached" early return kept this
|
||||
subscription bound to the dead coordinator, so other clients froze
|
||||
for that object until a page reload (bug audit 2026-08-29). Track
|
||||
the coordinator instance and re-attach when it changed.
|
||||
"""
|
||||
rd = _get_runtime_data(hass, entry_id)
|
||||
if rd and rd.coordinator:
|
||||
coordinator = rd.coordinator if rd else None
|
||||
if coordinator is None:
|
||||
return
|
||||
previous = attached_entry_ids.get(entry_id)
|
||||
if previous is not None:
|
||||
if previous[0] is coordinator:
|
||||
return
|
||||
previous[1]()
|
||||
unsub_callbacks.remove(previous[1])
|
||||
|
||||
@callback
|
||||
def _on_update(eid: str = entry_id) -> None:
|
||||
_forward_update(eid)
|
||||
@callback
|
||||
def _on_update(eid: str = entry_id) -> None:
|
||||
_forward_update(eid)
|
||||
|
||||
unsub_callbacks.append(rd.coordinator.async_add_listener(_on_update))
|
||||
attached_entry_ids.add(entry_id)
|
||||
unsub = coordinator.async_add_listener(_on_update)
|
||||
unsub_callbacks.append(unsub)
|
||||
attached_entry_ids[entry_id] = (coordinator, unsub)
|
||||
|
||||
# Register listeners on all existing coordinators
|
||||
entries = _get_object_entries(hass)
|
||||
@@ -677,6 +695,17 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
|
||||
tids.append(v)
|
||||
filtered[CONF_DISABLED_TEMPLATE_IDS] = tids
|
||||
|
||||
# Validate shopping_list_entity if provided: "" clears; otherwise it must
|
||||
# be a todo.* entity id. Format-only — NO existence check (the list's
|
||||
# integration may load later; the sync self-heals on first appearance).
|
||||
if CONF_SHOPPING_LIST_ENTITY in filtered:
|
||||
raw_ent = (filtered[CONF_SHOPPING_LIST_ENTITY] or "").strip()
|
||||
if raw_ent and not valid_entity_id(raw_ent):
|
||||
return filtered, "invalid_shopping_list_entity"
|
||||
if raw_ent and not raw_ent.startswith("todo."):
|
||||
return filtered, "invalid_shopping_list_entity"
|
||||
filtered[CONF_SHOPPING_LIST_ENTITY] = raw_ent
|
||||
|
||||
# Validate notify_service if provided
|
||||
if CONF_NOTIFY_SERVICE in filtered:
|
||||
from ..config_flow_options_global import validate_notify_service
|
||||
|
||||
@@ -140,7 +140,7 @@ async def ws_adopt_integration_setups(
|
||||
# Second dedup layer (discovery already hides these): never create
|
||||
# a task whose name — in any language — already exists on the
|
||||
# target object.
|
||||
from ..helpers.integration_signatures import task_name_variants
|
||||
from ..helpers.integration_signatures import proposal_name_variants
|
||||
|
||||
existing_names = {
|
||||
str(t.get("name", "")).lower()
|
||||
@@ -151,9 +151,12 @@ async def ws_adopt_integration_setups(
|
||||
for task in setup["tasks"]:
|
||||
if task["task_name"] not in wanted:
|
||||
continue
|
||||
if existing_names & task_name_variants(task["task_name"]):
|
||||
# Per-entity duties (colour cartridges) carry the entity label
|
||||
# in task_name; the catalog key and the label travel separately.
|
||||
catalog_name = task.get("catalog_task_name") or task["task_name"]
|
||||
if existing_names & proposal_name_variants(catalog_name, task.get("entity_label")):
|
||||
continue
|
||||
sig = sig_by_key[(task["task_name"], task["direction"])]
|
||||
sig = sig_by_key[(catalog_name, task["direction"])]
|
||||
trigger = build_setup_trigger(sig, hass, task["entity_ids"])
|
||||
# #102: "last service was at reading X" — usage_delta only.
|
||||
# Other directions either already have absolute semantics
|
||||
@@ -167,7 +170,9 @@ async def ws_adopt_integration_setups(
|
||||
{
|
||||
"id": uuid4().hex,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": localize_template_text(task["task_name"], lang) or task["task_name"],
|
||||
"name": task.get("task_name_localized")
|
||||
or localize_template_text(task["task_name"], lang)
|
||||
or task["task_name"],
|
||||
"type": "replacement",
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "manual"},
|
||||
|
||||
@@ -14,6 +14,11 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
BATTERY_FLEET_EXCLUDED,
|
||||
BATTERY_FLEET_INCLUDED,
|
||||
BATTERY_FLEET_OBJECT_FLAG,
|
||||
BATTERY_FLEET_TASK_FLAG,
|
||||
BATTERY_FLEET_TRACK_SELF_CHARGING,
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_MANUFACTURER,
|
||||
CONF_OBJECT_MODEL,
|
||||
@@ -89,6 +94,57 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _import_fleet_identity(
|
||||
hass: HomeAssistant,
|
||||
obj_data: dict[str, Any],
|
||||
import_obj: dict[str, Any],
|
||||
obj_name: str,
|
||||
) -> bool:
|
||||
"""Restore the battery-fleet markers onto ``import_obj``; True if it is the fleet.
|
||||
|
||||
The exported flag is honoured only while this instance has NO fleet yet
|
||||
(``find_fleet_entry`` returns the FIRST flagged entry, so a second flagged
|
||||
object would silently shadow or be shadowed by the existing one). The
|
||||
exclude/include lists are re-validated like the live WS writes: entity
|
||||
ids only, deduped + sorted, capped at ``FLEET_LIST_CAP``.
|
||||
"""
|
||||
if obj_data.get(BATTERY_FLEET_OBJECT_FLAG) is not True:
|
||||
return False
|
||||
|
||||
from homeassistant.core import valid_entity_id
|
||||
|
||||
from ..helpers.battery_fleet_setup import FLEET_LIST_CAP, find_fleet_entry
|
||||
|
||||
if find_fleet_entry(hass) is not None:
|
||||
_LOGGER.warning(
|
||||
"JSON import: %r is flagged as the Battery Fleet but this instance already has one — importing it as a plain object",
|
||||
obj_name,
|
||||
)
|
||||
return False
|
||||
|
||||
import_obj[BATTERY_FLEET_OBJECT_FLAG] = True
|
||||
for key in (BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED):
|
||||
raw_list = obj_data.get(key)
|
||||
if not isinstance(raw_list, list):
|
||||
continue
|
||||
cleaned = sorted({e.strip() for e in raw_list if isinstance(e, str) and valid_entity_id(e.strip())})
|
||||
if cleaned:
|
||||
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
|
||||
return True
|
||||
|
||||
|
||||
def _keep_fleet_part_id(is_fleet: bool, old_id: str) -> bool:
|
||||
"""Whether an imported part keeps its id instead of getting a fresh uuid.
|
||||
|
||||
Only the fleet's deterministic type-part ids (``batt_<type>``, minted by
|
||||
battery_fleet_setup._type_part) — every other part id is re-minted so an
|
||||
import can never collide with or impersonate an existing part.
|
||||
"""
|
||||
return is_fleet and old_id.startswith("batt_") and len(old_id) <= MAX_ID_LENGTH
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/version"})
|
||||
@websocket_api.async_response
|
||||
async def ws_version(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
|
||||
@@ -557,12 +613,26 @@ async def ws_import_json(
|
||||
"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"),
|
||||
# Object-level archive marker — same presence-means-archived
|
||||
# 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")),
|
||||
"task_ids": [],
|
||||
}
|
||||
|
||||
# Battery fleet identity (object flag + exclude/include lists + the
|
||||
# self-charging opt-in). The fleet is ONE object by invariant
|
||||
# (find_fleet_entry returns the first flagged entry), so the flag is
|
||||
# only restored when this instance has no fleet yet — otherwise the
|
||||
# payload imports as a plain object (fresh part ids, no task flag).
|
||||
is_fleet = _import_fleet_identity(hass, obj_data, import_obj, obj_name)
|
||||
|
||||
# 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.
|
||||
# Fleet type-parts keep their deterministic ``batt_<type>`` ids: the
|
||||
# fleet reconcile / mark-replaced paths key on them, so a re-minted
|
||||
# uuid would orphan the whole battery-type ↔ part mapping.
|
||||
from uuid import uuid4 as _uuid4
|
||||
|
||||
part_id_map: dict[str, str] = {}
|
||||
@@ -574,7 +644,7 @@ async def ws_import_json(
|
||||
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
|
||||
new_id = old_id if _keep_fleet_part_id(is_fleet, old_id) else _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
|
||||
@@ -597,6 +667,7 @@ async def ws_import_json(
|
||||
# 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] = {}
|
||||
fleet_task_seen = False
|
||||
tasks_list = obj_entry.get("tasks", [])
|
||||
if not isinstance(tasks_list, list):
|
||||
tasks_list = []
|
||||
@@ -642,6 +713,7 @@ async def ws_import_json(
|
||||
"documentation_url",
|
||||
"custom_icon",
|
||||
"nfc_tag_id",
|
||||
"require_tag_scan",
|
||||
"responsible_user_id",
|
||||
"entity_slug",
|
||||
"trigger_config",
|
||||
@@ -667,6 +739,13 @@ async def ws_import_json(
|
||||
if val is not None:
|
||||
task_data[key] = val
|
||||
|
||||
# The fleet's single aggregate task keeps its marker (detail view
|
||||
# renders the battery section; the fleet reconcile repairs its
|
||||
# trigger). Only ONE task may carry it, and only on the fleet.
|
||||
if is_fleet and task_entry.get(BATTERY_FLEET_TASK_FLAG) is True and not fleet_task_seen:
|
||||
task_data[BATTERY_FLEET_TASK_FLAG] = True
|
||||
fleet_task_seen = True
|
||||
|
||||
# In-cycle checklist ticks: keyed by item TEXT so they survive the
|
||||
# id regeneration; keys are filtered against the imported checklist
|
||||
# exactly like the live checklist_progress WS write. Rides
|
||||
|
||||
@@ -94,6 +94,9 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
|
||||
# naive = local). Backfills a past completion; must not be in the
|
||||
# future — the coordinator validates and splits latest-vs-backfill.
|
||||
vol.Optional("completed_at"): vol.Any(vol.All(str, vol.Length(max=64)), None),
|
||||
# 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").
|
||||
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
||||
@@ -201,6 +204,10 @@ async def ws_complete_task(
|
||||
# This is also what feeds the `least_completed` rotation strategy
|
||||
# and what satisfies a task requiring the "user" detail.
|
||||
completed_by=connection.user.id if connection.user else None,
|
||||
# The panel sets this only on the QR deep-link fallback (a scanned
|
||||
# sticker whose task has no quick-complete defaults) - same trust
|
||||
# level as task/quick_complete (bug audit 2026-08-29).
|
||||
tag_verified=bool(msg.get("via_tag_scan")),
|
||||
)
|
||||
except ServiceValidationError as err:
|
||||
# Validation refusals (missing required details, future completed_at).
|
||||
@@ -270,12 +277,15 @@ async def ws_quick_complete_task(
|
||||
cost=defaults.get("cost"),
|
||||
duration=defaults.get("duration"),
|
||||
feedback=defaults.get("feedback"),
|
||||
# The QR sticker hangs ON the thing — scanning it is presence.
|
||||
tag_verified=True,
|
||||
completed_by=connection.user.id if connection.user else None,
|
||||
)
|
||||
except ServiceValidationError as err:
|
||||
# The task demands details the quick-complete defaults do not cover —
|
||||
# same fallback as `no_defaults`: the caller opens the full dialog.
|
||||
connection.send_error(msg["id"], "completion_details_required", str(err))
|
||||
# Other refusals (too_early, task_inactive) keep their own key.
|
||||
connection.send_error(msg["id"], err.translation_key or "completion_details_required", str(err))
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True, "via": "quick"})
|
||||
|
||||
@@ -384,7 +394,7 @@ async def ws_set_task_phase(
|
||||
)
|
||||
return
|
||||
rd.store.set_phase_cursor(msg["task_id"], msg["cursor"])
|
||||
rd.store.async_delay_save()
|
||||
await rd.store.async_save() # user action - never rely on the 60 s debounce
|
||||
await rd.coordinator.async_refresh_now()
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ TASK_UPDATE_FIELD_MAP = {
|
||||
"entity_slug": "entity_slug",
|
||||
"custom_icon": "custom_icon",
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"require_tag_scan": "require_tag_scan",
|
||||
"reading_unit": "reading_unit",
|
||||
"consumes_parts": "consumes_parts",
|
||||
"phases": "phases",
|
||||
@@ -192,6 +193,8 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
|
||||
# Proof of presence (#139 family): completion only via NFC/QR scan.
|
||||
vol.Optional("require_tag_scan"): vol.Any(bool, None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# Spare parts consumed on completion: [{part_id, quantity}].
|
||||
@@ -367,6 +370,8 @@ async def ws_create_task(
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
tc_warnings.append(nfc_warn)
|
||||
if msg.get("require_tag_scan") is not None:
|
||||
task_data["require_tag_scan"] = bool(msg["require_tag_scan"])
|
||||
# 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
|
||||
@@ -465,6 +470,8 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
|
||||
# Proof of presence (#139 family): completion only via NFC/QR scan.
|
||||
vol.Optional("require_tag_scan"): vol.Any(bool, None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# Spare parts consumed on completion: [{part_id, quantity}].
|
||||
@@ -793,6 +800,14 @@ async def async_delete_task(
|
||||
# Clean up group references
|
||||
cleanup_group_refs(hass, task_id=task_id)
|
||||
|
||||
# Drop the task's document links (task_ids / task_pages in the global
|
||||
# DocumentStore) — persistent, task-id keyed, and otherwise never pruned.
|
||||
from ..const import DOCUMENT_STORE_KEY
|
||||
|
||||
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
||||
if doc_store is not None:
|
||||
await doc_store.async_unlink_task(task_id)
|
||||
|
||||
# Clean up the global vacation exempt list (journey L1): the list is
|
||||
# persistent and task-id keyed — without this, deleted ids accumulate
|
||||
# there forever and confuse the vacation preview UI.
|
||||
|
||||
Reference in new issue
Block a user