189 files
This commit is contained in:
1 parent
79fefc0a7c
commit
303886f212
189 files changed
+21402
-3514
No files matched your search
@@ -11,6 +11,7 @@ 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
|
||||
@@ -24,13 +25,15 @@ 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,
|
||||
)
|
||||
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, read_batteries
|
||||
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, note_sibling_entity, read_batteries
|
||||
from .trigger_fallback import threshold_limits_overlap
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -77,6 +80,10 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
|
||||
existing = find_fleet_entry(hass)
|
||||
if existing is not None:
|
||||
# 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
|
||||
@@ -318,7 +325,12 @@ def replaced_button_for(battery_plus_entity_id: str) -> str:
|
||||
.replace("_battery_plus_low", "_battery_replaced")
|
||||
.replace("_battery_plus", "_battery_replaced")
|
||||
)
|
||||
return battery_plus_entity_id.replace("sensor.", "button.", 1).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]:
|
||||
@@ -341,6 +353,10 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
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 not None:
|
||||
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
|
||||
pressed += 1
|
||||
@@ -443,6 +459,17 @@ def set_track_self_charging(hass: HomeAssistant, enabled: bool) -> bool:
|
||||
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():
|
||||
@@ -580,15 +607,7 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
legacy = parts.get("batt_unknown")
|
||||
if legacy is not None:
|
||||
stock = store.get_part_stock("batt_unknown") if store is not None else None
|
||||
untouched = (
|
||||
not stock
|
||||
and not legacy.get("product_url")
|
||||
and not legacy.get("vendor")
|
||||
and not legacy.get("mpn")
|
||||
and not legacy.get("gtin")
|
||||
and not legacy.get("auto_buy_task")
|
||||
)
|
||||
if untouched:
|
||||
if _legacy_unknown_untouched(entry, legacy, stock):
|
||||
parts.pop("batt_unknown")
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_PARTS] = parts
|
||||
@@ -612,10 +631,14 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
uid = reg_entry.unique_id or ""
|
||||
if "_part_" not in uid:
|
||||
continue
|
||||
pid = uid.split("_part_", 1)[1]
|
||||
if pid and pid not in parts:
|
||||
ent_reg.async_remove(reg_entry.entity_id)
|
||||
orphans_removed += 1
|
||||
# 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:
|
||||
@@ -625,6 +648,45 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
|
||||
|
||||
|
||||
# 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)."""
|
||||
@@ -632,10 +694,11 @@ def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[s
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user