198 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-11 23:51:59 +00:00
1 parent 4a64770282
commit 669d229519
198 files changed
+4891 -2815

No files matched your search

@@ -40,6 +40,8 @@ from typing import Any, cast
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import DEFAULT_BATTERY_LOW_PERCENT
_LOGGER = logging.getLogger(__name__)
# The typical-lifetime table, the household overrides and the learned values
@@ -51,6 +53,7 @@ from .battery_lifetime import (
has_type_forecast,
is_rechargeable_type,
is_shoppable_type,
is_swappable_cell,
lifetime_resolver,
observe_replacements,
table_lifetime_months,
@@ -521,12 +524,23 @@ def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
# latched: their binary saying "not low" is the all-clear, exactly as before.
# Sensorless notes (D#162) leave ``low`` only when their forecast re-anchors.
#
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced}})
# #205: each entry also keeps the low threshold it latched at. A threshold
# lowered since then (the household's battery_low_percent, a Battery Notes
# threshold) is a decision, not a hover: a battery above the NEW threshold
# is released at once instead of waiting for the recovered level, which
# kept 15 % batteries on the list after the floor went from 20 % to 10 %.
#
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced, threshold}})
# so a restart does not re-open the episode; without a fleet the latch lives
# in memory (the low-count sensor exists before the fleet does).
LOW_LATCH_KEY = "battery_low_latch"
_LATCH_MEMORY_KEY = "battery_fleet_low_latch_memory"
# What a latch from before #205 (no threshold of its own) is taken to have
# latched at: the household default, the floor almost every such latch
# was set under. (A floor lowered long before leaves such a latch released
# once it reads above it; a battery that is really low latches again.)
_LEGACY_LATCH_THRESHOLD = float(DEFAULT_BATTERY_LOW_PERCENT)
def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
@@ -556,6 +570,9 @@ def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
continue
last = entry.get("last_replaced")
out[key] = {"at": at.strip()[:40], "last_replaced": last[:40] if isinstance(last, str) else None}
threshold = entry.get("threshold")
if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) and 0 <= threshold <= 100:
out[key]["threshold"] = float(threshold)
return out
@@ -587,7 +604,10 @@ def apply_low_latch(
and the fresh reading is STILL low — a new episode);
* latched + unavailable → stays low (no reading is no proof of recovery);
* latched + reading not low → released when the level is above
``recovered`` or a replacement was recorded, else held low.
``recovered`` or a replacement was recorded, else held low;
* latched + the battery's low threshold lowered since it latched
(#205) and the level above the new one → released (not a recovery:
it never reaches ``released_by_level``, nothing is recorded).
``released_by_level`` (#181 follow-up) collects the ids released by their
LEVEL alone — no newer replacement date on the note — which is the
@@ -604,9 +624,15 @@ def apply_low_latch(
last = bat.last_replaced.isoformat() if bat.last_replaced else None
raw_entry = latch.get(bat.entity_id)
entry = raw_entry if isinstance(raw_entry, dict) else None
threshold = float(bat.low_threshold)
if bat.low:
if entry is None or _replaced_since(last, entry):
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last}
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last, "threshold": threshold}
changed = True
elif entry.get("threshold") != threshold:
# Low at the CURRENT threshold: the episode belongs to it
# (a rebound after this is held, as always).
latch[bat.entity_id] = {**entry, "threshold": threshold}
changed = True
continue
if entry is None:
@@ -617,6 +643,15 @@ def apply_low_latch(
del latch[bat.entity_id]
changed = True
continue
latched_at = entry.get("threshold")
if not isinstance(latched_at, (int, float)):
latched_at = max(_LEGACY_LATCH_THRESHOLD, threshold)
latch[bat.entity_id] = {**entry, "threshold": latched_at}
changed = True
if bat.available and bat.level is not None and threshold < latched_at and bat.level > threshold:
del latch[bat.entity_id]
changed = True
continue
bat.low = True
bat.latched = True
known = {b.entity_id for b in batteries}
@@ -1590,6 +1625,24 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
return OrderedDict(sorted(totals.items()))
def discover_rechargeable_cells(hass: HomeAssistant) -> OrderedDict[str, int]:
"""Rechargeable cell types across the fleet → total quantity (#208).
Kept apart from :func:`discover_battery_types` (cells anyone BUYS): a
swappable rechargeable cell ("AAA NiMH", "18650") gets a reusable part
instead — no reorder threshold, no buy link; a swap takes charged cells
and puts the empty ones on the "to charge" pile. A pack built into the
device gets nothing.
"""
totals: OrderedDict[str, int] = OrderedDict()
for bat in read_batteries(hass):
if not is_swappable_cell(bat.battery_type):
continue
t = canonical_type(bat.battery_type)
totals[t] = totals.get(t, 0) + bat.quantity
return OrderedDict(sorted(totals.items()))
__all__ = [
"DEFAULT_HORIZON_DAYS",
"LOW_LATCH_KEY",
@@ -1606,6 +1659,7 @@ __all__ = [
"canonical_type",
"compute_overview",
"discover_battery_types",
"discover_rechargeable_cells",
"fleet_due_without_sensor",
"fleet_excluded_entities",
"fleet_included_entities",