1089 lines
47 KiB
Python
1089 lines
47 KiB
Python
"""One-click setup of the Battery Fleet: an object whose PARTS are battery
|
|
types and whose single task aggregates all low batteries.
|
|
|
|
Design (see helpers/battery_fleet.py for the aggregation): the fleet is ONE
|
|
object; each battery TYPE present becomes a tracked spare-part (so the existing
|
|
stock/reorder machinery handles "order in time"); ONE task "Replace low
|
|
batteries" hangs off the global battery-low count sensor via an ordinary
|
|
threshold trigger. No per-battery task.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from ..const import (
|
|
BATTERY_FLEET_EXCLUDED,
|
|
BATTERY_FLEET_INCLUDED,
|
|
BATTERY_FLEET_OBJECT_FLAG,
|
|
BATTERY_FLEET_REMOVED_PARTS,
|
|
BATTERY_FLEET_TASK_FLAG,
|
|
CONF_OBJECT,
|
|
CONF_PARTS,
|
|
CONF_TASK_CONSUMES_PARTS,
|
|
CONF_TASKS,
|
|
DOMAIN,
|
|
GLOBAL_UNIQUE_ID,
|
|
)
|
|
from .battery_fleet import (
|
|
canonical_type,
|
|
discover_battery_types,
|
|
lifetime_months,
|
|
note_sibling_entity,
|
|
read_batteries,
|
|
release_low_latch,
|
|
)
|
|
from .entry_tasks import write_task, write_tasks
|
|
from .global_options import get_default_warning_days
|
|
from .trigger_fallback import threshold_limits_overlap
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# The global aggregate sensor the fleet task triggers on (fixed entity_id).
|
|
LOW_COUNT_ENTITY_ID = "sensor.maintenance_supporter_batteries_to_replace"
|
|
|
|
# Hygiene bound on the manual exclude/include lists (security review
|
|
# 2026-08-21): both are write-gated, but nothing else limited their growth —
|
|
# a scripted operator could bloat the fleet object's config entry without
|
|
# bound. 2000 is far above any real fleet.
|
|
FLEET_LIST_CAP = 2000
|
|
|
|
# Marker on the object + task so the panel renders the battery detail section
|
|
# and setup is idempotent (never a second fleet).
|
|
OBJECT_FLAG = BATTERY_FLEET_OBJECT_FLAG
|
|
TASK_FLAG = BATTERY_FLEET_TASK_FLAG
|
|
|
|
|
|
def find_fleet_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
|
"""The existing Battery Fleet object entry, or None."""
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.data.get(CONF_OBJECT, {}).get(OBJECT_FLAG):
|
|
return entry
|
|
return None
|
|
|
|
|
|
async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None = None) -> dict[str, Any]:
|
|
"""Create (or return) the Battery Fleet object with type-parts + the task.
|
|
|
|
Idempotent: a second call reconciles the type-parts against the current
|
|
fleet (adds parts for newly-seen types) and returns the existing entry.
|
|
``language`` is the caller's UI language (same contract as the template
|
|
WS): the created object/task/part names and notes are localized through
|
|
the ``_T`` table, falling back to the server language, then English.
|
|
"""
|
|
from ..websocket.objects import async_create_object
|
|
from ..websocket.tasks_persist import async_persist_task
|
|
from .i18n import normalize_language, normalize_language_code
|
|
from .parts import normalize_part
|
|
|
|
lang = normalize_language_code(language) if language else normalize_language(hass)
|
|
types = discover_battery_types(hass) # {TYPE: total_qty}
|
|
|
|
existing = find_fleet_entry(hass)
|
|
if existing is not None:
|
|
# Fold alias-typed parts (batt_lr6 → batt_aa) BEFORE the reconcile
|
|
# would mint the canonical twin next to them.
|
|
migrated = await migrate_fleet_part_ids(hass, existing)
|
|
# An explicit setup asks for the full type-part set again — forget
|
|
# the parts deleted since (the start-up reconcile honours them).
|
|
if existing.data.get(CONF_OBJECT, {}).get(BATTERY_FLEET_REMOVED_PARTS):
|
|
_mutate_fleet_object(hass, lambda obj: obj.pop(BATTERY_FLEET_REMOVED_PARTS, None))
|
|
added_pids = _reconcile_type_parts(hass, existing, types, lang)
|
|
# Track stock at 0 for the parts just added — the CREATE path below
|
|
# does, and a part left untracked (stock None) shows no stock line
|
|
# and never flags for reorder. Found on a real fleet: types added by
|
|
# a later reconcile sat untracked next to setup-created "0 pcs/2"
|
|
# siblings, silently disarming their reorder thresholds.
|
|
if added_pids:
|
|
rd = getattr(existing, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
if store is not None:
|
|
for pid in added_pids:
|
|
store.set_part_stock(pid, 0)
|
|
await store.async_save()
|
|
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
|
if migrated and not repaired:
|
|
# The live stock sensors still carry the old part ids — reload
|
|
# once so they come back under the canonical ones (the task
|
|
# repair above reloads on its own).
|
|
await hass.config_entries.async_reload(existing.entry_id)
|
|
return {
|
|
"entry_id": existing.entry_id,
|
|
"created": False,
|
|
"types": list(types),
|
|
"parts_added": len(added_pids),
|
|
"parts_migrated": migrated,
|
|
"task_repaired": repaired,
|
|
}
|
|
|
|
from ..templates import localize_template_text
|
|
|
|
entry_id = await async_create_object(hass, name=localize_template_text("Battery Fleet", lang) or "Battery Fleet")
|
|
entry = hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None: # pragma: no cover — just created above
|
|
raise HomeAssistantError("Battery Fleet object entry vanished after creation")
|
|
|
|
# Flag the object + attach a type-part per battery type present.
|
|
new_data = dict(entry.data)
|
|
obj = dict(new_data.get(CONF_OBJECT, {}))
|
|
obj[OBJECT_FLAG] = True
|
|
new_data[CONF_OBJECT] = obj
|
|
parts: dict[str, dict[str, Any]] = {}
|
|
for btype, total_qty in types.items():
|
|
part = normalize_part(_type_part(btype, total_qty, lang))
|
|
parts[part["id"]] = part
|
|
new_data[CONF_PARTS] = parts
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
|
|
# Track stock at 0 for each type (user counts their drawer later).
|
|
rd = getattr(entry, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
if store is not None:
|
|
for pid in parts:
|
|
store.set_part_stock(pid, 0)
|
|
await store.async_save()
|
|
|
|
# The single aggregate task, triggered by the global low-count sensor.
|
|
task = _fleet_task(obj.get("id", ""), lang, warning_days=get_default_warning_days(hass))
|
|
await async_persist_task(hass, entry, task)
|
|
|
|
return {
|
|
"entry_id": entry_id,
|
|
"created": True,
|
|
"types": list(types),
|
|
"parts_added": len(parts),
|
|
"task_id": task["id"],
|
|
}
|
|
|
|
|
|
def _fleet_trigger_config() -> dict[str, Any]:
|
|
"""The canonical fleet-task trigger: threshold >0 on the low-count sensor.
|
|
|
|
Carries BOTH the singular ``entity_id`` and plural ``entity_ids`` — the
|
|
task-dialog's save path gates on the singular field, and a (possibly
|
|
cached) frontend that hydrates only ``entity_id`` would otherwise wipe
|
|
the trigger on an unrelated edit (issue #106).
|
|
"""
|
|
return {
|
|
"type": "threshold",
|
|
"entity_id": LOW_COUNT_ENTITY_ID,
|
|
"entity_ids": [LOW_COUNT_ENTITY_ID],
|
|
"trigger_above": 0,
|
|
"entity_logic": "any",
|
|
"auto_complete_on_recovery": True,
|
|
}
|
|
|
|
|
|
def _fleet_task(obj_id: str, lang: str, *, warning_days: int) -> dict[str, Any]:
|
|
"""The single aggregate fleet task, with localized name + notes.
|
|
|
|
``warning_days`` is the household default like every other task
|
|
creator writes (it was the one task record without it)."""
|
|
from ..templates import localize_template_text
|
|
|
|
return {
|
|
"id": uuid4().hex,
|
|
"object_id": obj_id,
|
|
"name": localize_template_text("Replace low batteries", lang) or "Replace low batteries",
|
|
"type": "inspection",
|
|
"enabled": True,
|
|
TASK_FLAG: True,
|
|
"schedule": {"kind": "manual"},
|
|
"warning_days": warning_days,
|
|
"trigger_config": _fleet_trigger_config(),
|
|
"created_at": dt_util.now().date().isoformat(),
|
|
"notes": localize_template_text(
|
|
"Aggregate battery check. The detail view lists which devices are low and which battery types to buy.",
|
|
lang,
|
|
),
|
|
}
|
|
|
|
|
|
def _type_part(btype: str, total_qty: int, lang: str) -> dict[str, Any]:
|
|
"""A spare-part definition for one battery type (localized name/notes).
|
|
|
|
reorder_threshold defaults to keeping a spare set roughly the size of the
|
|
fleet's need for that type (min 2); restock is double that. auto_buy_task
|
|
stays off so setup never spawns extra buy-tasks — the fleet task's detail
|
|
is the shopping surface; the user can enable auto-buy per type later.
|
|
"""
|
|
from ..templates import localize_template_text
|
|
|
|
threshold = max(2, total_qty)
|
|
name_tpl = localize_template_text("{type} battery", lang) or "{type} battery"
|
|
notes_tpl = localize_template_text("Typical service life ~{months} months.", lang) or "Typical service life ~{months} months."
|
|
return {
|
|
"id": f"batt_{btype.lower()}",
|
|
"name": name_tpl.format(type=btype),
|
|
"unit": "pcs",
|
|
"reorder_threshold": threshold,
|
|
"restock_quantity": threshold * 2,
|
|
"auto_buy_task": False,
|
|
"notes": notes_tpl.format(months=lifetime_months(btype)),
|
|
}
|
|
|
|
|
|
# ── retranslating the seeded texts (issue #115) ────────────────────────────
|
|
#
|
|
# Setup writes the object/task/part names and notes ONCE, in the language that
|
|
# happened to be current — and nothing ever touched them again, so a fleet set
|
|
# up before localized seeding existed (v2.38) keeps English notes forever while
|
|
# every runtime string around them is translated. The panel's runtime strings
|
|
# follow the UI language; these stored ones can only follow it if something
|
|
# rewrites them.
|
|
#
|
|
# The rule that makes rewriting safe: a stored text is only replaced when it
|
|
# EXACTLY matches one of the known template variants (any language), i.e. the
|
|
# user never edited it. Anything the user typed stays.
|
|
|
|
|
|
def _template_variants(text_en: str) -> set[str]:
|
|
"""The English template plus every translation of it."""
|
|
from ..templates_i18n import _T
|
|
|
|
return {text_en} | set(_T.get(text_en, {}).values())
|
|
|
|
|
|
def _extract_placeholder(stored: str, text_en: str, placeholder: str) -> str | None:
|
|
"""The placeholder's value, if ``stored`` matches any variant of the template.
|
|
|
|
``"AA-batteri"`` against ``"{type} battery"`` finds the Danish variant
|
|
``"{type}-batteri"``, matches prefix/suffix and returns ``"AA"``. Returns
|
|
None when no variant matches — the user rewrote the text.
|
|
"""
|
|
token = "{" + placeholder + "}"
|
|
for variant in _template_variants(text_en):
|
|
prefix, sep, suffix = variant.partition(token)
|
|
if not sep:
|
|
continue
|
|
if stored.startswith(prefix) and stored.endswith(suffix) and len(stored) > len(prefix) + len(suffix):
|
|
return stored[len(prefix) : len(stored) - len(suffix)]
|
|
return None
|
|
|
|
|
|
def retranslate_seeded_texts(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> bool:
|
|
"""Bring untouched seeded texts into ``lang``. Returns True when changed.
|
|
|
|
Runs at every fleet-entry setup: cheap string comparisons, and idempotent —
|
|
once the texts are in the current language they match that language's
|
|
variant and are rewritten to themselves.
|
|
|
|
The boot-time caller passes the SERVER language, so untouched seeded texts
|
|
converge on it — the same convention notifications and digests follow,
|
|
because stored data is shared by every user of the instance. Seeding still
|
|
honours the caller's UI language for the immediate result; if the two
|
|
differ, the next reload converges.
|
|
"""
|
|
from ..templates import localize_template_text
|
|
|
|
changed = False
|
|
data = dict(entry.data)
|
|
|
|
def _localized(text_en: str) -> str:
|
|
return localize_template_text(text_en, lang) or text_en
|
|
|
|
obj = dict(data.get(CONF_OBJECT) or {})
|
|
if obj.get("name") in _template_variants("Battery Fleet") and obj.get("name") != _localized("Battery Fleet"):
|
|
obj["name"] = _localized("Battery Fleet")
|
|
data[CONF_OBJECT] = obj
|
|
changed = True
|
|
|
|
tasks = dict(data.get(CONF_TASKS) or {})
|
|
for task_id, task in tasks.items():
|
|
if not task.get(TASK_FLAG):
|
|
continue
|
|
new_task = dict(task)
|
|
if new_task.get("name") in _template_variants("Replace low batteries"):
|
|
new_task["name"] = _localized("Replace low batteries")
|
|
notes_en = "Aggregate battery check. The detail view lists which devices are low and which battery types to buy."
|
|
if new_task.get("notes") in _template_variants(notes_en):
|
|
new_task["notes"] = _localized(notes_en)
|
|
if new_task != task:
|
|
tasks[task_id] = new_task
|
|
data[CONF_TASKS] = tasks
|
|
changed = True
|
|
|
|
parts = dict(data.get(CONF_PARTS) or {})
|
|
for part_id, part in parts.items():
|
|
if not str(part_id).startswith("batt_"):
|
|
continue
|
|
new_part = dict(part)
|
|
btype = _extract_placeholder(str(new_part.get("name") or ""), "{type} battery", "type")
|
|
if btype is not None:
|
|
new_part["name"] = (_localized("{type} battery")).format(type=btype)
|
|
months = _extract_placeholder(str(new_part.get("notes") or ""), "Typical service life ~{months} months.", "months")
|
|
if months is not None:
|
|
new_part["notes"] = (_localized("Typical service life ~{months} months.")).format(months=months)
|
|
if new_part != part:
|
|
parts[part_id] = new_part
|
|
data[CONF_PARTS] = parts
|
|
changed = True
|
|
|
|
if changed:
|
|
title = (data.get(CONF_OBJECT) or {}).get("name") or entry.title
|
|
hass.config_entries.async_update_entry(entry, data=data, title=title)
|
|
return changed
|
|
|
|
|
|
def replaced_button_for(battery_plus_entity_id: str) -> str:
|
|
"""The Battery Notes 'replaced' button entity id for a battery_plus sensor.
|
|
|
|
Battery Notes mints them in parallel: sensor.<x>_battery_plus ->
|
|
button.<x>_battery_replaced.
|
|
"""
|
|
# #186: a native row backed by a sibling type note (the plus entities are
|
|
# gone) — the note's button follows the same contract off the native id.
|
|
obj = battery_plus_entity_id.split(".", 1)[1]
|
|
if not any(obj.endswith(s) for s in ("_battery_plus", "_battery_plus_low", "_battery_type")):
|
|
return f"button.{obj}_battery_replaced"
|
|
# #121 low-only notes are binary_sensor.<x>_battery_plus_low; their button
|
|
# is still button.<x>_battery_replaced (bug audit 2026-08-29).
|
|
if battery_plus_entity_id.startswith("binary_sensor."):
|
|
return (
|
|
battery_plus_entity_id.replace("binary_sensor.", "button.", 1)
|
|
.replace("_battery_plus_low", "_battery_replaced")
|
|
.replace("_battery_plus", "_battery_replaced")
|
|
)
|
|
# D#162 sensorless notes are read from sensor.<x>_battery_type — same button.
|
|
return (
|
|
battery_plus_entity_id.replace("sensor.", "button.", 1)
|
|
.replace("_battery_plus", "_battery_replaced")
|
|
.replace("_battery_type", "_battery_replaced")
|
|
)
|
|
|
|
|
|
async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None = None) -> dict[str, Any]:
|
|
"""Mark batteries replaced: press their Battery Notes 'replaced' button
|
|
(records the replacement date → resets the forecast) and consume the
|
|
matching type-part spares from stock.
|
|
|
|
``entity_ids`` = battery_plus sensors to mark; default = all currently low.
|
|
The fleet task auto-completes on its own once the devices report fresh
|
|
(low count → 0), so this does NOT complete the task directly (which would
|
|
race that recovery).
|
|
|
|
The cells consumed per battery are the note's own ``battery_quantity``
|
|
(Battery Notes, per device) — never a task's ``consumes_parts`` link
|
|
quantity. Setup gives the fleet task no such links (#181 follow-up); a
|
|
link added by hand is consumed by the task's own completions on top.
|
|
"""
|
|
by_eid = {b.entity_id: b for b in read_batteries(hass)}
|
|
targets = entity_ids if entity_ids is not None else [e for e, b in by_eid.items() if b.low]
|
|
|
|
pressed = 0
|
|
pressed_ids: list[str] = []
|
|
by_type: dict[str, int] = {}
|
|
for eid in targets:
|
|
bat = by_eid.get(eid)
|
|
if bat is None:
|
|
continue
|
|
button = replaced_button_for(eid)
|
|
if hass.states.get(button) is None:
|
|
# Renamed entity ids break the naming contract — the registry
|
|
# still knows the note's button (D#162 rows depend on it).
|
|
button = note_sibling_entity(hass, eid, domain="button", uid_suffix="_battery_replaced_button") or button
|
|
if hass.states.get(button) is None:
|
|
# Nothing to press → nothing was replaced: no date is written, so
|
|
# consuming stock here would double-charge the next real press.
|
|
continue
|
|
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
|
|
pressed += 1
|
|
pressed_ids.append(eid)
|
|
t = canonical_type(bat.battery_type)
|
|
by_type[t] = by_type.get(t, 0) + bat.quantity
|
|
|
|
# #180: a marked battery leaves the low latch now — the count must not
|
|
# wait for Battery Notes to echo the new date (nor for the level).
|
|
release_low_latch(hass, pressed_ids)
|
|
consumed = await async_consume_type_parts(hass, by_type)
|
|
return {"marked": pressed, "pressed": pressed, "consumed": consumed}
|
|
|
|
|
|
async def async_consume_type_parts(hass: HomeAssistant, by_type: dict[str, int]) -> dict[str, int]:
|
|
"""Take ``{canonical type: cells}`` out of the fleet's type-part stock.
|
|
Returns ``{part_id: qty}`` for the parts that exist (an untyped or
|
|
unstocked type consumes nothing). The ONE consumption path behind the
|
|
Replaced action and the record-replacement command (#181)."""
|
|
consumed: dict[str, int] = {}
|
|
fleet = find_fleet_entry(hass)
|
|
if fleet is None or not by_type:
|
|
return consumed
|
|
from ..parts_runtime import async_change_part_stock
|
|
|
|
parts = fleet.data.get(CONF_PARTS) or {}
|
|
for btype, qty in by_type.items():
|
|
pid = f"{FLEET_PART_PREFIX}{btype.lower()}"
|
|
if pid in parts:
|
|
await async_change_part_stock(hass, fleet, pid, delta=-qty)
|
|
consumed[pid] = qty
|
|
return consumed
|
|
|
|
|
|
# Store key on the fleet task's dynamic state: {entity_id: iso date} of the
|
|
# replacements recorded through the record-replacement command — the
|
|
# idempotency memory (Battery Notes echoes the date only after its service
|
|
# ran; a repeat click for the same day must not consume twice).
|
|
RECORDED_REPLACEMENTS_KEY = "battery_recorded_replacements"
|
|
_RECORDED_CAP = 500
|
|
|
|
|
|
async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced_at: str) -> dict[str, Any]:
|
|
"""Record a replacement the level history revealed (#181, the roster's
|
|
calendar-sync chip): write the date through Battery Notes'
|
|
``set_battery_replaced`` — the same call the panel used to make itself
|
|
— AND consume the battery type's cells from the type-part stock through
|
|
:func:`async_consume_type_parts`, exactly like the Replaced action.
|
|
|
|
Idempotent per calendar day: the same date for the same battery is
|
|
recorded again (harmless) but never consumed twice. Raises
|
|
:class:`HomeAssistantError` with a WS-ready code when the battery is
|
|
unknown, has no device, or Battery Notes is not available.
|
|
|
|
The quantity consumed is the note's ``battery_quantity`` (Battery Notes,
|
|
per device), not a task link's. Also the path behind the automatic
|
|
record on a level-driven recovery (#181 follow-up, setting
|
|
``battery_auto_record_recovery``, see battery_fleet._schedule_auto_record).
|
|
"""
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from .battery_lifetime import fleet_store_and_task
|
|
|
|
parsed = dt_util.parse_datetime(replaced_at)
|
|
if parsed is None:
|
|
raise HomeAssistantError("invalid_date")
|
|
bat = next((b for b in read_batteries(hass) if b.entity_id == entity_id), None)
|
|
if bat is None:
|
|
raise HomeAssistantError("not_found")
|
|
reg = er.async_get(hass).async_get(entity_id)
|
|
if reg is None or not reg.device_id:
|
|
raise HomeAssistantError("invalid_device")
|
|
if not hass.services.has_service("battery_notes", "set_battery_replaced"):
|
|
raise HomeAssistantError("not_available")
|
|
|
|
day = (dt_util.as_local(parsed) if parsed.tzinfo is not None else parsed).date().isoformat()
|
|
found = fleet_store_and_task(hass)
|
|
recorded: dict[str, Any] = {}
|
|
if found is not None:
|
|
raw = found[0].get_task_state(found[1]).get(RECORDED_REPLACEMENTS_KEY)
|
|
recorded = dict(raw) if isinstance(raw, dict) else {}
|
|
already = recorded.get(entity_id) == day or (bat.last_replaced is not None and bat.last_replaced.isoformat() == day)
|
|
|
|
await hass.services.async_call(
|
|
"battery_notes",
|
|
"set_battery_replaced",
|
|
{"device_id": reg.device_id, "datetime_replaced": parsed.isoformat()},
|
|
blocking=False,
|
|
)
|
|
release_low_latch(hass, [entity_id])
|
|
|
|
consumed: dict[str, int] = {}
|
|
if not already:
|
|
consumed = await async_consume_type_parts(hass, {canonical_type(bat.battery_type): bat.quantity})
|
|
if found is not None:
|
|
store, task_id = found
|
|
recorded[entity_id] = day
|
|
if len(recorded) > _RECORDED_CAP:
|
|
for old in list(recorded)[: len(recorded) - _RECORDED_CAP]:
|
|
recorded.pop(old, None)
|
|
store.update_task_state(task_id, **{RECORDED_REPLACEMENTS_KEY: recorded})
|
|
store.async_delay_save()
|
|
return {"recorded": True, "already_recorded": already, "consumed": consumed}
|
|
|
|
|
|
def _mutate_fleet_object(hass: HomeAssistant, mutate: Callable[[dict[str, Any]], None]) -> bool:
|
|
"""Find the fleet entry, apply ``mutate`` to a copy of its object dict,
|
|
persist. Returns False when no fleet exists yet — the shared scaffolding
|
|
the three setters below each hand-rolled (drift audit 2026-08)."""
|
|
entry = find_fleet_entry(hass)
|
|
if entry is None:
|
|
return False
|
|
new_data = dict(entry.data)
|
|
obj = dict(new_data.get(CONF_OBJECT, {}))
|
|
mutate(obj)
|
|
new_data[CONF_OBJECT] = obj
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
return True
|
|
|
|
|
|
def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) -> bool:
|
|
"""Persist a manual exclude/include of one battery on the fleet object.
|
|
|
|
Issue #107: some tracked batteries should never appear (a rechargeable
|
|
device the heuristics missed, a neighbour's sensor, …). Stored as
|
|
``battery_fleet_excluded`` on the fleet object dict. Returns False when
|
|
no fleet exists yet.
|
|
"""
|
|
|
|
def _apply(obj: dict[str, Any]) -> None:
|
|
current = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
|
|
if excluded:
|
|
# Symmetry (#135): hiding a manually-added battery removes the
|
|
# manual include instead of stacking an exclusion on top of it.
|
|
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
|
|
if entity_id in includes:
|
|
includes.discard(entity_id)
|
|
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
|
|
else:
|
|
if len(current) >= FLEET_LIST_CAP and entity_id not in current:
|
|
raise HomeAssistantError(f"Battery fleet exclusion list is full ({FLEET_LIST_CAP})")
|
|
current.add(entity_id)
|
|
else:
|
|
current.discard(entity_id)
|
|
obj[BATTERY_FLEET_EXCLUDED] = sorted(current)
|
|
|
|
return _mutate_fleet_object(hass, _apply)
|
|
|
|
|
|
def set_battery_included(hass: HomeAssistant, entity_id: str, included: bool) -> bool:
|
|
"""Persist a manual ADD of one battery to the fleet (#135).
|
|
|
|
The include bypasses the discovery heuristic and the self-charging filter
|
|
in the aggregation — for batteries the heuristics miss. Adding also lifts
|
|
a previous exclusion (the user changed their mind). Returns False when no
|
|
fleet exists yet.
|
|
"""
|
|
|
|
def _apply(obj: dict[str, Any]) -> None:
|
|
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
|
|
if included:
|
|
if len(includes) >= FLEET_LIST_CAP and entity_id not in includes:
|
|
raise HomeAssistantError(f"Battery fleet include list is full ({FLEET_LIST_CAP})")
|
|
includes.add(entity_id)
|
|
excludes = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
|
|
if entity_id in excludes:
|
|
excludes.discard(entity_id)
|
|
obj[BATTERY_FLEET_EXCLUDED] = sorted(excludes)
|
|
else:
|
|
includes.discard(entity_id)
|
|
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
|
|
|
|
return _mutate_fleet_object(hass, _apply)
|
|
|
|
|
|
def set_track_self_charging(hass: HomeAssistant, enabled: bool) -> bool:
|
|
"""Persist the fleet-wide track-self-charging opt-in (#135 follow-up).
|
|
|
|
Stored on the fleet object like the exclude/include lists. Returns False
|
|
when no fleet exists yet.
|
|
"""
|
|
from ..const import BATTERY_FLEET_TRACK_SELF_CHARGING
|
|
|
|
return _mutate_fleet_object(hass, lambda obj: obj.__setitem__(BATTERY_FLEET_TRACK_SELF_CHARGING, bool(enabled)))
|
|
|
|
|
|
def set_due_without_sensor(hass: HomeAssistant, enabled: bool) -> bool:
|
|
"""Persist the fleet-wide due-without-sensor option (D#162).
|
|
|
|
On by default; stored as False to switch off. Returns False when no
|
|
fleet exists yet.
|
|
"""
|
|
from ..const import BATTERY_FLEET_DUE_WITHOUT_SENSOR
|
|
|
|
return _mutate_fleet_object(hass, lambda obj: obj.__setitem__(BATTERY_FLEET_DUE_WITHOUT_SENSOR, bool(enabled)))
|
|
|
|
|
|
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
|
|
"""The flagged fleet task (id, data) on the fleet entry, or None."""
|
|
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
|
|
if task_data.get(TASK_FLAG):
|
|
return task_id, task_data
|
|
return None
|
|
|
|
|
|
def fleet_task_trigger_ok(entry: ConfigEntry) -> bool:
|
|
"""Whether the fleet task exists and still carries a usable trigger.
|
|
|
|
A user edit can wipe the trigger (issue #106: the dialog nulled a trigger
|
|
stored with only the plural ``entity_ids``); without it the task never
|
|
fires or auto-completes. This is the health signal behind the repair path.
|
|
"""
|
|
from ..entity.triggers import normalize_entity_ids
|
|
|
|
found = find_fleet_task(entry)
|
|
if found is None:
|
|
return False
|
|
tc = found[1].get("trigger_config") or {}
|
|
return tc.get("type") == "threshold" and LOW_COUNT_ENTITY_ID in normalize_entity_ids(tc)
|
|
|
|
|
|
async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> bool:
|
|
"""Repair the fleet task if broken. Returns True when something was fixed.
|
|
|
|
* Trigger lost (issue #106) → restore the canonical threshold trigger,
|
|
keeping the user's name/type/translations untouched.
|
|
* Task deleted entirely → recreate it fresh (localized).
|
|
"""
|
|
from ..websocket.tasks_persist import async_persist_task
|
|
|
|
if fleet_task_trigger_ok(entry):
|
|
return False
|
|
|
|
found = find_fleet_task(entry)
|
|
if found is not None:
|
|
task_id, task_data = found
|
|
new_task = dict(task_data)
|
|
new_task["trigger_config"] = _fleet_trigger_config()
|
|
write_task(hass, entry, task_id, new_task)
|
|
await hass.config_entries.async_reload(entry.entry_id)
|
|
return True
|
|
|
|
obj = entry.data.get(CONF_OBJECT, {})
|
|
await async_persist_task(hass, entry, _fleet_task(obj.get("id", ""), lang, warning_days=get_default_warning_days(hass)))
|
|
return True
|
|
|
|
|
|
def _heal_fleet_trigger_recovery_flag(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""#156: fleet tasks minted before ``auto_complete_on_recovery`` joined
|
|
the canonical trigger keep showing Triggered after the batteries were
|
|
swapped — the low count drops to 0 but nothing records the completion,
|
|
and ``fleet_task_trigger_ok`` (type+entity only) never repairs it. Add
|
|
the missing flag, and ONLY the flag: a deliberately edited threshold or
|
|
entity list stays untouched. Runs at every start, so restored backups
|
|
from old exports heal too.
|
|
"""
|
|
from ..entity.triggers import normalize_entity_ids
|
|
|
|
found = find_fleet_task(entry)
|
|
if found is None:
|
|
return False
|
|
task_id, task_data = found
|
|
tc = task_data.get("trigger_config") or {}
|
|
if tc.get("type") != "threshold" or LOW_COUNT_ENTITY_ID not in normalize_entity_ids(tc):
|
|
return False
|
|
if tc.get("auto_complete_on_recovery"):
|
|
return False
|
|
new_task = dict(task_data)
|
|
new_task["trigger_config"] = {**tc, "auto_complete_on_recovery": True}
|
|
write_task(hass, entry, task_id, new_task)
|
|
return True
|
|
|
|
|
|
def _warn_fleet_trigger_overlap(entry: ConfigEntry) -> bool:
|
|
"""#156: a hand-edited fleet trigger with ``trigger_below`` above
|
|
``trigger_above`` is always tripped (the limits are OR-ed), so the task
|
|
never recovers and never auto-completes. Newer editors refuse the
|
|
combination; stored configs from before that only get this log line —
|
|
the user's edit is not silently rewritten. Returns True when warned.
|
|
"""
|
|
found = find_fleet_task(entry)
|
|
if found is None:
|
|
return False
|
|
_task_id, task_data = found
|
|
tc = task_data.get("trigger_config") or {}
|
|
above, below = tc.get("trigger_above"), tc.get("trigger_below")
|
|
if not (isinstance(above, (int, float)) and isinstance(below, (int, float))):
|
|
return False
|
|
if not threshold_limits_overlap(float(above), float(below)):
|
|
return False
|
|
_LOGGER.warning(
|
|
"Battery fleet task '%s' has 'trigger below' (%s) higher than 'trigger above' (%s): "
|
|
"the limits are OR-ed, so the task is always triggered and can never auto-complete. "
|
|
"Clear 'Trigger below' in the task editor (issue #156).",
|
|
task_data.get("name", _task_id),
|
|
below,
|
|
above,
|
|
)
|
|
return True
|
|
|
|
|
|
async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> dict[str, Any]:
|
|
"""Boot-time reconcile of the fleet's type-parts (issue #148).
|
|
|
|
A battery whose TYPE only becomes known after the fleet was set up (a
|
|
Battery Notes note added later, or a low-only Matter lock whose typed
|
|
``…_battery_plus_low`` binary arrived with #121) never got its spare
|
|
part: ``_reconcile_type_parts`` only ran on an explicit fleet-setup
|
|
call. Runs the same reconcile once per start — and prunes the legacy
|
|
"UNKNOWN battery" part older versions minted for untyped batteries,
|
|
but ONLY while it is completely untouched (no stock counted, no
|
|
product/vendor data, auto-buy off); the moment a user touched it, it
|
|
is theirs and stays.
|
|
"""
|
|
trigger_healed = _heal_fleet_trigger_recovery_flag(hass, entry)
|
|
_warn_fleet_trigger_overlap(entry)
|
|
|
|
# Alias-typed parts first (batt_lr6 → batt_aa): the reconcile below mints
|
|
# parts by CANONICAL type and would otherwise add the twin next to them.
|
|
migrated = await migrate_fleet_part_ids(hass, entry)
|
|
|
|
types = discover_battery_types(hass)
|
|
added = _reconcile_type_parts(hass, entry, types, lang)
|
|
|
|
pruned = False
|
|
rd = getattr(entry, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
|
|
parts = dict(entry.data.get(CONF_PARTS) or {})
|
|
legacy = parts.get("batt_unknown")
|
|
if legacy is not None:
|
|
stock = store.get_part_stock("batt_unknown") if store is not None else None
|
|
if _legacy_unknown_untouched(entry, legacy, stock):
|
|
parts.pop("batt_unknown")
|
|
new_data = dict(entry.data)
|
|
new_data[CONF_PARTS] = parts
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
pruned = True
|
|
if store is not None:
|
|
store.remove_part("batt_unknown")
|
|
|
|
# #148 follow-up (v2.70.0 aftermath): the prune must not leave the
|
|
# part's stock sensor behind as an unavailable orphan in the entity
|
|
# registry — the official ws_delete_part removes that entry, the v2.70.0
|
|
# prune did not, and HA greys out Delete while the integration still
|
|
# claims the entity. Sweep every part-stock registry entry whose part no
|
|
# longer exists; this also heals installs where the v2.70.0 prune
|
|
# already ran (the part is gone, only the registry corpse remains).
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
ent_reg = er.async_get(hass)
|
|
orphans_removed = 0
|
|
for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
|
uid = reg_entry.unique_id or ""
|
|
if "_part_" not in uid:
|
|
continue
|
|
# Match the way the sensor mints it (``…_{object_slug}_part_{pid}``)
|
|
# from the END — splitting at the first "_part_" mis-parsed a slug
|
|
# that itself contains "_part_" and swept the live sensor (bug
|
|
# review 2026-09-04).
|
|
if any(uid.endswith(f"_part_{pid}") for pid in parts):
|
|
continue
|
|
ent_reg.async_remove(reg_entry.entity_id)
|
|
orphans_removed += 1
|
|
|
|
if store is not None and (added or pruned):
|
|
for pid in added:
|
|
store.set_part_stock(pid, 0)
|
|
await store.async_save()
|
|
return {
|
|
"added": added,
|
|
"pruned": pruned,
|
|
"orphans_removed": orphans_removed,
|
|
"trigger_healed": trigger_healed,
|
|
"migrated": migrated,
|
|
}
|
|
|
|
|
|
# ── canonical part ids (DRY audit 2026-09) ─────────────────────────────────
|
|
#
|
|
# Part ids are ``batt_<type>`` and the type used to be the label merely
|
|
# upper-cased, while the lifetime table, the overrides and the learning
|
|
# already folded aliases (LR6 → AA, PP3 → 9V, "AA Lithium" → AA). A note typed
|
|
# "LR6" therefore minted its own part and shopping chip although the forecast
|
|
# treated it as AA — and an AA override silently applied to the LR6 row.
|
|
# Everything groups by ``canonical_type`` now; the ids already persisted are
|
|
# folded once, here, with every reference following.
|
|
|
|
FLEET_PART_PREFIX = "batt_"
|
|
|
|
|
|
def canonical_part_id(part_id: str) -> str | None:
|
|
"""``batt_<label>`` → ``batt_<canonical label>``; None when the id is not
|
|
a fleet type-part id or already canonical."""
|
|
if not part_id.startswith(FLEET_PART_PREFIX):
|
|
return None
|
|
label = part_id[len(FLEET_PART_PREFIX) :]
|
|
if not label:
|
|
return None
|
|
new_id = f"{FLEET_PART_PREFIX}{canonical_type(label).lower()}"
|
|
return None if new_id == part_id else new_id
|
|
|
|
|
|
_MERGE_TAKE_IF_EMPTY = ("mpn", "gtin", "vendor", "storage_location", "product_url", "cost", "doc_id")
|
|
|
|
|
|
def _merge_parts(keep: dict[str, Any], gone: dict[str, Any]) -> dict[str, Any]:
|
|
"""Fold ``gone`` into ``keep`` (the canonical part wins on conflicts):
|
|
the LOWER reorder threshold, the union of product/vendor/document
|
|
fields, auto-buy if either had it, notes concatenated when they differ."""
|
|
out = dict(keep)
|
|
for key in _MERGE_TAKE_IF_EMPTY:
|
|
if not out.get(key) and gone.get(key):
|
|
out[key] = gone[key]
|
|
for key in ("reorder_threshold", "restock_quantity"):
|
|
a, b = out.get(key), gone.get(key)
|
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
out[key] = min(a, b)
|
|
elif a is None and b is not None:
|
|
out[key] = b
|
|
out["auto_buy_task"] = bool(out.get("auto_buy_task")) or bool(gone.get("auto_buy_task"))
|
|
gone_notes = str(gone.get("notes") or "")
|
|
keep_notes = str(out.get("notes") or "")
|
|
if gone_notes and gone_notes != keep_notes and _extract_placeholder(gone_notes, "Typical service life ~{months} months.", "months") is None:
|
|
# The user's own note on the folded part — keep it (the seeded
|
|
# "typical service life" line is dropped, the canonical part has one).
|
|
out["notes"] = f"{keep_notes}\n{gone_notes}".strip() if keep_notes else gone_notes
|
|
return out
|
|
|
|
|
|
def _qty_sum(a: Any, b: Any) -> int | float:
|
|
"""Sum two link quantities (default 1 each); stays an int when both are."""
|
|
|
|
def _num(x: Any) -> int | float:
|
|
if isinstance(x, bool) or not isinstance(x, (int, float)):
|
|
return 1
|
|
return x
|
|
|
|
total = _num(a) + _num(b)
|
|
return int(total) if float(total).is_integer() else total
|
|
|
|
|
|
def _repoint_links(links: Any, moves: dict[str, str], *, owner_id: str, foreign: bool) -> tuple[list[Any], bool]:
|
|
"""Re-point ``{part_id, quantity, entry_id?}`` links (task consumption
|
|
AND history ``used_parts``) at the canonical ids, merging two links that
|
|
now name the same part. ``foreign`` = the links live on ANOTHER object,
|
|
so only pooled links carrying our ``entry_id`` are ours to touch."""
|
|
if not isinstance(links, list):
|
|
return links, False
|
|
out: list[Any] = []
|
|
by_key: dict[tuple[str | None, str], dict[str, Any]] = {}
|
|
changed = False
|
|
for link in links:
|
|
if not isinstance(link, dict):
|
|
out.append(link)
|
|
continue
|
|
pid = str(link.get("part_id") or "")
|
|
owner = str(link.get("entry_id") or "").strip() or None
|
|
ours = owner == owner_id if foreign else owner in (None, owner_id)
|
|
if pid in moves and ours:
|
|
link = {**link, "part_id": moves[pid]}
|
|
pid = moves[pid]
|
|
changed = True
|
|
key = (owner, pid)
|
|
twin = by_key.get(key)
|
|
if twin is not None and pid:
|
|
# Two links to what is now ONE part: fold the quantities.
|
|
twin["quantity"] = _qty_sum(twin.get("quantity"), link.get("quantity"))
|
|
changed = True
|
|
continue
|
|
link = dict(link)
|
|
by_key[key] = link
|
|
out.append(link)
|
|
return out, changed
|
|
|
|
|
|
def _repoint_store_history(store: Any, task_ids: list[str], moves: dict[str, str], *, owner_id: str, foreign: bool) -> int:
|
|
"""``used_parts`` on history entries in ONE Store. Returns entries touched."""
|
|
touched = 0
|
|
for tid in task_ids:
|
|
history = store.get_history(tid)
|
|
new_history: list[dict[str, Any]] = []
|
|
changed = False
|
|
for entry in history:
|
|
used = entry.get("used_parts") if isinstance(entry, dict) else None
|
|
if used is None:
|
|
new_history.append(entry)
|
|
continue
|
|
new_used, c = _repoint_links(used, moves, owner_id=owner_id, foreign=foreign)
|
|
if c:
|
|
entry = {**entry, "used_parts": new_used}
|
|
changed = True
|
|
touched += 1
|
|
new_history.append(entry)
|
|
if changed:
|
|
store.set_history(tid, new_history)
|
|
return touched
|
|
|
|
|
|
def _repoint_tasks(tasks: dict[str, Any], moves: dict[str, str], *, owner_id: str, foreign: bool) -> tuple[dict[str, Any], bool]:
|
|
"""Consumption links (and, on the fleet itself, the auto-buy ``part_ref``)."""
|
|
from .parts import PART_REF_FIELD
|
|
|
|
out: dict[str, Any] = {}
|
|
changed = False
|
|
for tid, task in tasks.items():
|
|
links, c = _repoint_links(task.get(CONF_TASK_CONSUMES_PARTS), moves, owner_id=owner_id, foreign=foreign)
|
|
if c:
|
|
task = {**task, CONF_TASK_CONSUMES_PARTS: links}
|
|
changed = True
|
|
ref = task.get(PART_REF_FIELD) if not foreign else None
|
|
if isinstance(ref, dict) and str(ref.get("part_id") or "") in moves:
|
|
task = {**task, PART_REF_FIELD: {**ref, "part_id": moves[str(ref["part_id"])]}}
|
|
changed = True
|
|
out[tid] = task
|
|
return out, changed
|
|
|
|
|
|
async def migrate_fleet_part_ids(hass: HomeAssistant, entry: ConfigEntry) -> dict[str, str]:
|
|
"""Fold every ``batt_<alias>`` part onto ``batt_<canonical>`` — once.
|
|
|
|
Idempotent (a canonical id maps to itself and is skipped). For each
|
|
part whose id is not canonical:
|
|
|
|
* the canonical part already exists → MERGE: stock summed, the lower
|
|
reorder threshold, product/vendor/document fields unioned, auto-buy
|
|
OR-ed, the user's own notes appended; the alias part is deleted.
|
|
* otherwise → RENAME in place (the untouched seeded name "LR6 battery"
|
|
becomes "AA battery").
|
|
|
|
In both cases every reference follows: consumption links on the fleet's
|
|
own tasks and on other objects' tasks that pool from the fleet, the
|
|
``part_ref`` of an auto-buy task, ``used_parts`` on history entries in
|
|
every Store, the deleted-parts tombstones, the stock, and the stock
|
|
sensor's registry entry (renamed so a customised entity id survives;
|
|
removed when the canonical sensor exists). Returns ``{old: new}``.
|
|
"""
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from ..const import STORES_CACHE_KEY
|
|
|
|
parts = dict(entry.data.get(CONF_PARTS) or {})
|
|
moves: dict[str, str] = {}
|
|
for pid in parts:
|
|
new_id = canonical_part_id(str(pid))
|
|
if new_id is not None:
|
|
moves[str(pid)] = new_id
|
|
|
|
obj = dict(entry.data.get(CONF_OBJECT) or {})
|
|
tombstones = [str(t) for t in (obj.get(BATTERY_FLEET_REMOVED_PARTS) or [])]
|
|
live_after = (set(parts) - set(moves)) | set(moves.values())
|
|
new_tombstones = sorted({canonical_part_id(t) or t for t in tombstones} - live_after)
|
|
tombstones_changed = new_tombstones != sorted(set(tombstones))
|
|
if not moves and not tombstones_changed:
|
|
return {}
|
|
|
|
rd = getattr(entry, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
if store is None:
|
|
store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
|
|
|
|
# 1. The part definitions + stock.
|
|
for old, new in moves.items():
|
|
gone = dict(parts.pop(old))
|
|
if new in parts:
|
|
parts[new] = _merge_parts(parts[new], gone)
|
|
_LOGGER.info("Battery fleet: merged part %s into %s (alias type)", old, new)
|
|
else:
|
|
gone["id"] = new
|
|
name = str(gone.get("name") or "")
|
|
label = _extract_placeholder(name, "{type} battery", "type")
|
|
if label is not None and label.lower() == old[len(FLEET_PART_PREFIX) :]:
|
|
gone["name"] = name.replace(label, canonical_type(label), 1)
|
|
parts[new] = gone
|
|
_LOGGER.info("Battery fleet: renamed part %s to %s (canonical type)", old, new)
|
|
if store is not None:
|
|
old_stock, new_stock = store.get_part_stock(old), store.get_part_stock(new)
|
|
if old_stock is not None or new_stock is not None:
|
|
store.set_part_stock(new, (old_stock or 0) + (new_stock or 0))
|
|
store.remove_part(old)
|
|
|
|
# 2. The fleet's own tasks (links + auto-buy refs) and its history.
|
|
tasks, _ = _repoint_tasks(dict(entry.data.get(CONF_TASKS) or {}), moves, owner_id=entry.entry_id, foreign=False)
|
|
new_data = dict(entry.data)
|
|
new_data[CONF_PARTS] = parts
|
|
new_data[CONF_TASKS] = tasks
|
|
if tombstones_changed:
|
|
obj = dict(obj)
|
|
if new_tombstones:
|
|
obj[BATTERY_FLEET_REMOVED_PARTS] = new_tombstones
|
|
else:
|
|
obj.pop(BATTERY_FLEET_REMOVED_PARTS, None)
|
|
new_data[CONF_OBJECT] = obj
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
if store is not None and moves:
|
|
_repoint_store_history(store, list(tasks), moves, owner_id=entry.entry_id, foreign=False)
|
|
await store.async_save()
|
|
|
|
if not moves:
|
|
return {}
|
|
|
|
# 3. Other objects pooling from the fleet (links carry our entry_id).
|
|
stores = hass.data.get(STORES_CACHE_KEY, {})
|
|
for other in hass.config_entries.async_entries(DOMAIN):
|
|
if other.entry_id == entry.entry_id or other.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
other_tasks = dict(other.data.get(CONF_TASKS) or {})
|
|
if not other_tasks:
|
|
continue
|
|
repointed, changed = _repoint_tasks(other_tasks, moves, owner_id=entry.entry_id, foreign=True)
|
|
if changed:
|
|
write_tasks(hass, other, repointed)
|
|
other_store = getattr(getattr(other, "runtime_data", None), "store", None) or stores.get(other.entry_id)
|
|
if other_store is not None and _repoint_store_history(other_store, list(other_tasks), moves, owner_id=entry.entry_id, foreign=True):
|
|
await other_store.async_save()
|
|
|
|
# 4. The stock sensors' registry entries (unique_id ends with _part_<id>).
|
|
ent_reg = er.async_get(hass)
|
|
for reg_entry in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
|
uid = reg_entry.unique_id or ""
|
|
for old, new in moves.items():
|
|
suffix = f"_part_{old}"
|
|
if not uid.endswith(suffix):
|
|
continue
|
|
new_uid = uid[: -len(old)] + new
|
|
if ent_reg.async_get_entity_id(reg_entry.domain, reg_entry.platform, new_uid) is None:
|
|
ent_reg.async_update_entity(reg_entry.entity_id, new_unique_id=new_uid)
|
|
else:
|
|
ent_reg.async_remove(reg_entry.entity_id)
|
|
break
|
|
return moves
|
|
|
|
|
|
|
|
# The notes text v2.38 minted before the seeded texts were localized.
|
|
_LEGACY_UNKNOWN_NOTES = re.compile(r"Typical service life ~\d+ months( \(editorial\))?\.")
|
|
|
|
|
|
def _legacy_unknown_untouched(entry: ConfigEntry, legacy: dict[str, Any], stock: Any) -> bool:
|
|
"""Whether the legacy "UNKNOWN battery" part still looks exactly the way
|
|
older versions minted it — the only state the start-up prune may remove.
|
|
|
|
Bug review 2026-09-04: the v2.70 check looked at stock, product data and
|
|
auto-buy only. A part the user had RENAMED, annotated, priced, re-
|
|
thresholded, linked to a document or wired into a task's consumption
|
|
was still "untouched" and vanished silently on the next start.
|
|
"""
|
|
if stock:
|
|
return False
|
|
if any(
|
|
legacy.get(key)
|
|
for key in ("product_url", "vendor", "mpn", "gtin", "auto_buy_task", "cost", "doc_id", "storage_location")
|
|
):
|
|
return False
|
|
if _extract_placeholder(str(legacy.get("name") or ""), "{type} battery", "type") is None:
|
|
return False
|
|
notes = str(legacy.get("notes") or "")
|
|
if (
|
|
notes
|
|
and _extract_placeholder(notes, "Typical service life ~{months} months.", "months") is None
|
|
and not _LEGACY_UNKNOWN_NOTES.fullmatch(notes)
|
|
):
|
|
return False
|
|
threshold = legacy.get("reorder_threshold")
|
|
if not (isinstance(threshold, (int, float)) and threshold >= 2 and legacy.get("restock_quantity") == threshold * 2):
|
|
return False
|
|
for task in (entry.data.get(CONF_TASKS) or {}).values():
|
|
links = task.get(CONF_TASK_CONSUMES_PARTS)
|
|
if isinstance(links, list) and any(isinstance(x, dict) and x.get("part_id") == "batt_unknown" for x in links):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> list[str]:
|
|
"""Add parts for battery types newly seen since setup. Returns added ids
|
|
(the caller initializes their stock, mirroring the create path)."""
|
|
from .parts import normalize_part
|
|
|
|
parts = dict(entry.data.get(CONF_PARTS) or {})
|
|
existing_ids = set(parts)
|
|
removed = set(entry.data.get(CONF_OBJECT, {}).get(BATTERY_FLEET_REMOVED_PARTS) or [])
|
|
added: list[str] = []
|
|
for btype, total_qty in types.items():
|
|
pid = f"batt_{btype.lower()}"
|
|
if pid not in existing_ids and pid not in removed:
|
|
parts[pid] = normalize_part(_type_part(btype, total_qty, lang))
|
|
added.append(pid)
|
|
if added:
|
|
new_data = dict(entry.data)
|
|
new_data[CONF_PARTS] = parts
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
return added
|