429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
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.