217 files

This commit is contained in:
Home Assistant Version Control
2026-09-16 15:29:53 +00:00
parent 40c604b6dd
commit ef9868f192
217 changed files with 864 additions and 34306 deletions
@@ -495,6 +495,18 @@ def get_battery_recovered_percent(hass: HomeAssistant) -> int:
return value if lo <= value <= hi else DEFAULT_BATTERY_RECOVERED_PERCENT
def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
"""#181 follow-up: whether a battery the latch releases BY ITS LEVEL gets
its replacement recorded automatically (the Battery Notes date + the
type's cells from stock, see :func:`_schedule_auto_record`). Advanced
option, off by default."""
from ..const import CONF_BATTERY_AUTO_RECORD_RECOVERY
from .global_options import get_global_options
from .settings_registry import setting_default
return bool(get_global_options(hass).get(CONF_BATTERY_AUTO_RECORD_RECOVERY, setting_default(CONF_BATTERY_AUTO_RECORD_RECOVERY)))
# ── #180: the low-recovery latch ─────────────────────────────────────────────
#
# A Hue dimmer's level oscillated around the low floor several times a day;
@@ -534,6 +546,7 @@ def apply_low_latch(
recovered: float,
now_iso: str,
eligible: set[str] | None = None,
released_by_level: list[str] | None = None,
) -> bool:
"""Pure latch step: mutate ``bat.low``/``bat.latched`` and the ``latch``
map in place. Returns True when the map changed (the caller persists).
@@ -547,6 +560,10 @@ def apply_low_latch(
* latched + reading not low → released when the level is above
``recovered`` or a replacement was recorded, else held low.
``released_by_level`` (#181 follow-up) collects the ids released by their
LEVEL alone — no newer replacement date on the note — which is the
auto-record hook's input: a recorded date already counts as recorded.
Entries for batteries no longer in the fleet are dropped.
"""
changed = False
@@ -566,6 +583,8 @@ def apply_low_latch(
if entry is None:
continue
if bat.available and (_replaced_since(last, entry) or (bat.level is not None and bat.level > recovered)):
if released_by_level is not None and not _replaced_since(last, entry):
released_by_level.append(bat.entity_id)
del latch[bat.entity_id]
changed = True
continue
@@ -601,10 +620,96 @@ def _latch_backend(hass: HomeAssistant) -> tuple[dict[str, Any], Callable[[], No
def _apply_low_latch(hass: HomeAssistant, batteries: list[Battery], eligible: set[str]) -> None:
latch, save = _latch_backend(hass)
released: list[str] = []
if apply_low_latch(
batteries, latch, recovered=float(get_battery_recovered_percent(hass)), now_iso=dt_util.utcnow().isoformat(), eligible=eligible
batteries,
latch,
recovered=float(get_battery_recovered_percent(hass)),
now_iso=dt_util.utcnow().isoformat(),
eligible=eligible,
released_by_level=released,
):
# Persist BEFORE scheduling: the record path re-reads the fleet and
# must see the entry gone (or it would release — and schedule — again).
save()
if released and get_battery_auto_record_recovery(hass):
_schedule_auto_record(hass, [b for b in batteries if b.entity_id in released])
# ── #181 follow-up: auto-record a level-driven recovery ────────────────────
#
# With ``battery_auto_record_recovery`` on, a battery the latch releases
# BECAUSE ITS LEVEL rose above the recovery threshold — a fresh cell reporting,
# not a recorded date, which already counts as recorded — is recorded through
# the same path as the roster's calendar chip: ``async_record_replacement``
# writes the date to Battery Notes and consumes the type's cells (the note's
# own quantity) ONCE per day. Per battery, so a partial swap is recorded the
# moment that battery reports fresh; nothing waits for the fleet task.
#
# Scheduled as a task because ``read_batteries`` is sync (it runs inside the
# low-count sensor's update) and the record path re-reads the fleet itself.
# Re-entrancy: the latch entry is already gone when the task runs, so the
# nested read cannot release the same battery again; the in-flight set covers
# the window until then, and the record path's once-per-day memory makes a
# bounce that re-latches and recovers on the same day consume nothing twice.
_AUTO_RECORD_INFLIGHT_KEY = "battery_fleet_auto_record_inflight"
def _auto_record_inflight(hass: HomeAssistant) -> set[str]:
from ..const import DOMAIN
inflight: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(_AUTO_RECORD_INFLIGHT_KEY, set())
return inflight
def _schedule_auto_record(hass: HomeAssistant, batteries: list[Battery]) -> int:
"""Schedule the auto-record for batteries just released by their level.
Returns how many were scheduled: a native row has no Battery Notes note
to record on, and a rechargeable's recovery is a charge, not a swap."""
import threading
inflight = _auto_record_inflight(hass)
scheduled = 0
for bat in batteries:
if bat.source != "battery_notes":
_LOGGER.debug("Battery %s recovered but has no Battery Notes note - nothing to record on", bat.entity_id)
continue
if is_rechargeable_type(bat.battery_type):
_LOGGER.debug("Battery %s recovered by charging - no replacement to record", bat.entity_id)
continue
if bat.entity_id in inflight:
continue
inflight.add(bat.entity_id)
coro = _async_auto_record(hass, bat.entity_id)
if hass.loop_thread_id != threading.get_ident():
hass.create_task(coro, "maintenance_supporter_battery_auto_record")
else:
hass.async_create_task(coro, "maintenance_supporter_battery_auto_record", eager_start=False)
scheduled += 1
return scheduled
async def _async_auto_record(hass: HomeAssistant, entity_id: str) -> None:
from homeassistant.exceptions import HomeAssistantError
from .battery_fleet_setup import async_record_replacement
try:
result = await async_record_replacement(hass, entity_id, dt_util.utcnow().isoformat())
_LOGGER.info(
"Battery %s recovered above the threshold: replacement recorded automatically (consumed %s)",
entity_id,
result.get("consumed") or {},
)
except HomeAssistantError as err:
# not_available (Battery Notes gone), invalid_device (state-only
# note), not_found (excluded meanwhile) - nothing to record on.
_LOGGER.debug("Automatic replacement record for %s skipped: %s", entity_id, err)
except Exception: # noqa: BLE001 - a hook must never take the fleet down
_LOGGER.warning("Automatic replacement record for %s failed", entity_id, exc_info=True)
finally:
_auto_record_inflight(hass).discard(entity_id)
def release_low_latch(hass: HomeAssistant, entity_ids: list[str]) -> int:
@@ -1438,6 +1543,7 @@ __all__ = [
"fleet_excluded_entities",
"fleet_included_entities",
"fleet_track_self_charging",
"get_battery_auto_record_recovery",
"get_battery_recovered_percent",
"has_batteries",
"has_battery_notes",
@@ -364,6 +364,11 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
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]
@@ -436,6 +441,11 @@ async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced
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
@@ -44,6 +44,7 @@ from ..const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BATTERY_AUTO_RECORD_RECOVERY,
CONF_BATTERY_LIFETIME_MONTHS,
CONF_BATTERY_LOW_PERCENT,
CONF_BATTERY_RECOVERED_PERCENT,
@@ -135,6 +136,9 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, DEFAULT_BATTERY_LOW_PERCENT, int_range=(1, 90)),
# #180: a low battery stays low until its level rises ABOVE this (hysteresis).
SettingSpec(CONF_BATTERY_RECOVERED_PERCENT, int, DEFAULT_BATTERY_RECOVERED_PERCENT, int_range=BATTERY_RECOVERED_PERCENT_RANGE),
# #181 follow-up: a level-driven recovery records the replacement itself
# (Battery Notes date + stock) — advanced, off by default.
SettingSpec(CONF_BATTERY_AUTO_RECORD_RECOVERY, bool, False),
# D#182: shopping-search URL with a {q} placeholder ("" = automatic by
# country/language); the {q} requirement is a bespoke rule in dashboard.py.
SettingSpec(CONF_PART_SEARCH_URL_TEMPLATE, str, "", max_len=MAX_PART_SEARCH_URL_TEMPLATE_LENGTH),