278 files
This commit is contained in:
1 parent
d7a0a08372
commit
1b4f5f68c6
278 files changed
+36892
-7150
No files matched your search
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, cast
|
||||
@@ -41,26 +42,19 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Editorial typical service life per battery type, in MONTHS — the forecast
|
||||
# anchor (battery_last_replaced + lifetime = predicted replacement). These are
|
||||
# deliberately conservative sensor-use estimates and are meant to be tunable;
|
||||
# unknown types fall back to DEFAULT_LIFETIME_MONTHS.
|
||||
TYPICAL_LIFETIME_MONTHS: dict[str, int] = {
|
||||
"AAAA": 10,
|
||||
"AAA": 10,
|
||||
"AA": 12,
|
||||
"C": 18,
|
||||
"D": 24,
|
||||
"9V": 12,
|
||||
"CR2": 18,
|
||||
"CR123A": 18,
|
||||
"CR2032": 18,
|
||||
"CR2450": 24,
|
||||
"CR2477": 24,
|
||||
"CR2016": 18,
|
||||
"CR2025": 18,
|
||||
}
|
||||
DEFAULT_LIFETIME_MONTHS = 12
|
||||
# The typical-lifetime table, the household overrides and the learned values
|
||||
# live in battery_lifetime (D#162 follow-up); the names stay importable here.
|
||||
from .battery_lifetime import (
|
||||
TYPICAL_LIFETIME_MONTHS,
|
||||
LifetimeInfo,
|
||||
canonical_type,
|
||||
has_type_forecast,
|
||||
is_rechargeable_type,
|
||||
is_shoppable_type,
|
||||
lifetime_resolver,
|
||||
observe_replacements,
|
||||
table_lifetime_months,
|
||||
)
|
||||
|
||||
# How far ahead "needed soon" looks by default (days).
|
||||
DEFAULT_HORIZON_DAYS = 28
|
||||
@@ -152,33 +146,12 @@ def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
|
||||
return cache
|
||||
|
||||
|
||||
def _norm_type(raw: Any) -> str:
|
||||
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
|
||||
s = str(raw or "").strip()
|
||||
return s.upper() if s else "UNKNOWN"
|
||||
|
||||
|
||||
# Battery Notes' library labels rechargeable packs with type strings like
|
||||
# "Rechargeable", "Nuki Battery Pack" or li-ion cell names. Such a battery is
|
||||
# CHARGED, never bought — so it must not enter the shopping groupings, and the
|
||||
# type-lifetime table (a primary-cell prior) has nothing honest to say about
|
||||
# it. Low tracking and the discharge-trend forecast stay: "charge the lock in
|
||||
# ~20 days" is exactly what the roster is for.
|
||||
_RECHARGEABLE_TYPE_RE = re.compile(
|
||||
r"rechargeable|akku|accu|li[- ]?ion|li[- ]?po|lifepo|ni[- ]?mh|nicd|18650|21700|"
|
||||
r"power ?pack|battery ?pack|built[- ]?in",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_rechargeable_type(battery_type: Any) -> bool:
|
||||
"""Whether a battery-type label describes a rechargeable pack/cell."""
|
||||
return bool(_RECHARGEABLE_TYPE_RE.search(str(battery_type or "")))
|
||||
|
||||
|
||||
def lifetime_months(battery_type: str) -> int:
|
||||
"""Typical service life for a (canonicalized) battery type."""
|
||||
return TYPICAL_LIFETIME_MONTHS.get(_norm_type(battery_type), DEFAULT_LIFETIME_MONTHS)
|
||||
"""Typical service life for a battery type — the built-in table value
|
||||
(aliases such as LR6/PP3/CR123 folded onto the table key). The forecast
|
||||
itself goes through :func:`battery_lifetime.lifetime_resolver`, which
|
||||
puts the household's override and the learned value in front of it."""
|
||||
return table_lifetime_months(battery_type)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -210,6 +183,11 @@ class Battery:
|
||||
# read_batteries for EVERY Battery Notes row (a low-only binary row has
|
||||
# no level either and needs it just as much as a sensorless one).
|
||||
can_mark_replaced: bool = False
|
||||
# D#162 follow-up: "manufacturer|model" of the device (device registry)
|
||||
# — the pool a learned lifetime is drawn from. A CR2032 in a door sensor
|
||||
# and one in a thermostat share nothing but the cell; devices of the same
|
||||
# model do. Empty when the device is unknown.
|
||||
model_key: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -237,10 +215,11 @@ class BatteryOverview:
|
||||
return len(self.low)
|
||||
|
||||
|
||||
def _predicted_date(bat: Battery) -> date | None:
|
||||
def _predicted_date(bat: Battery, months: int | None = None) -> date | None:
|
||||
if bat.last_replaced is None:
|
||||
return None
|
||||
months = lifetime_months(bat.battery_type)
|
||||
if months is None:
|
||||
months = lifetime_months(bat.battery_type)
|
||||
# Month arithmetic without dateutil: add whole months, clamp the day.
|
||||
y, m = bat.last_replaced.year, bat.last_replaced.month + months
|
||||
y += (m - 1) // 12
|
||||
@@ -255,9 +234,15 @@ def build_overview(
|
||||
today: date,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
trend_predictions: dict[str, tuple[int, str]] | None = None,
|
||||
lifetime_for: Callable[[Battery], LifetimeInfo] | None = None,
|
||||
) -> BatteryOverview:
|
||||
"""Aggregate batteries into the fleet view.
|
||||
|
||||
``lifetime_for`` resolves a battery's lifetime (override > this device's
|
||||
own replacements > devices of the same model > table > default, see
|
||||
battery_lifetime); without it the table alone is used — the pure-function
|
||||
tests and the summary sensors call it that way.
|
||||
|
||||
* ``low`` = reported low right now (Battery Notes' own threshold) — or a
|
||||
sensorless note whose forecast has passed with the due-without-sensor
|
||||
option on (D#162; decided in :func:`read_batteries`).
|
||||
@@ -274,9 +259,17 @@ def build_overview(
|
||||
types_seen: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
|
||||
t = _norm_type(bat.battery_type)
|
||||
# ONE normalisation for grouping, part ids and the lifetime table
|
||||
# (DRY audit 2026-09): before, grouping only upper-cased, so an LR6
|
||||
# note minted its own part/chip while the forecast read it as AA.
|
||||
t = canonical_type(bat.battery_type)
|
||||
types_seen[t] = None
|
||||
rechargeable = is_rechargeable_type(bat.battery_type)
|
||||
# The shopping groupings list only types a part can stand behind —
|
||||
# the same predicate discover_battery_types mints parts by (a low
|
||||
# "UNKNOWN" / "Manual" battery used to land in needs_now with no
|
||||
# part to buy).
|
||||
shoppable = is_shoppable_type(bat.battery_type)
|
||||
# Blend (#114 follow-up): the DISCHARGE TREND wins where the recorder
|
||||
# data supports it (medium/high confidence, filtered upstream) — it is
|
||||
# device-specific and usage-aware; the type's typical lifetime is the
|
||||
@@ -286,11 +279,14 @@ def build_overview(
|
||||
# "replace the vacuum's pack" dated from the day the device was added),
|
||||
# so they get a ~date only when the trend has earned one.
|
||||
trend = (trend_predictions or {}).get(bat.entity_id)
|
||||
info = lifetime_for(bat) if lifetime_for else LifetimeInfo(lifetime_months(bat.battery_type), "table")
|
||||
if trend is not None:
|
||||
days_raw: int | None = trend[0]
|
||||
source, confidence = "trend", trend[1]
|
||||
else:
|
||||
pred = None if rechargeable else _predicted_date(bat)
|
||||
# No type lifetime for rechargeables (the table describes primary
|
||||
# cells) nor for "Manual" / "Irreplaceable" / "Solar" notes.
|
||||
pred = None if rechargeable or not has_type_forecast(bat.battery_type) else _predicted_date(bat, info.months)
|
||||
days_raw = (pred - today).days if pred is not None else None
|
||||
source, confidence = "typical", None
|
||||
# B1 (decided 2026-08): a PASSED prediction while the battery still
|
||||
@@ -308,21 +304,21 @@ def build_overview(
|
||||
# why it is due: keep the (negative) days and the overdue flag so
|
||||
# the roster can show the date the prediction ran out.
|
||||
if bat.no_sensor:
|
||||
low_row = _row(bat, t, days_raw, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue)
|
||||
low_row = _row(bat, t, days_raw, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info)
|
||||
else:
|
||||
low_row = _row(bat, t, None, rechargeable=rechargeable)
|
||||
low_row = _row(bat, t, None, rechargeable=rechargeable, lifetime=info)
|
||||
ov.low.append(low_row)
|
||||
if not rechargeable:
|
||||
if shoppable:
|
||||
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
|
||||
ov.all.append({**low_row, "status": "low"})
|
||||
continue
|
||||
if days is not None and days <= horizon_days:
|
||||
ov.soon.append(_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue))
|
||||
if not rechargeable:
|
||||
ov.soon.append(_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info))
|
||||
if shoppable:
|
||||
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue), "status": "soon"})
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info), "status": "soon"})
|
||||
continue
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable), "status": "ok"})
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, lifetime=info), "status": "ok"})
|
||||
|
||||
ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
|
||||
ov.types = sorted(types_seen)
|
||||
@@ -340,9 +336,17 @@ def _row(
|
||||
*,
|
||||
rechargeable: bool = False,
|
||||
forecast_overdue: bool = False,
|
||||
lifetime: LifetimeInfo | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"entity_id": bat.entity_id,
|
||||
# D#162 follow-up: which lifetime the "typical" forecast used and where
|
||||
# it came from — the roster tooltip says "18 months, learned from 5
|
||||
# replacements" instead of a bare date.
|
||||
"lifetime_months": lifetime.months if lifetime else None,
|
||||
"lifetime_source": lifetime.source if lifetime else None,
|
||||
"lifetime_samples": lifetime.samples if lifetime else 0,
|
||||
"model_key": bat.model_key,
|
||||
"device_name": bat.device_name,
|
||||
"battery_type": canon_type,
|
||||
"quantity": bat.quantity,
|
||||
@@ -371,13 +375,27 @@ def _row(
|
||||
}
|
||||
|
||||
|
||||
def _quantity_of(attrs: Any) -> int:
|
||||
"""``battery_quantity`` as a positive int; anything odd ("2 pcs", "1.0",
|
||||
None) is one cell - a template note with a bad value must not raise out
|
||||
of read_batteries and blank the whole fleet."""
|
||||
raw = attrs.get("battery_quantity") if hasattr(attrs, "get") else None
|
||||
try:
|
||||
qty = int(float(raw)) if raw not in (None, "") else 1
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return qty if qty >= 1 else 1
|
||||
|
||||
|
||||
def _parse_last_replaced(raw: Any) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = dt_util.parse_datetime(str(raw))
|
||||
if parsed is not None:
|
||||
return parsed.date()
|
||||
# An aware stamp (Battery Notes writes UTC) is a LOCAL calendar
|
||||
# date: 23:30 UTC on the 3rd is the 4th in Berlin.
|
||||
return (dt_util.as_local(parsed) if parsed.tzinfo is not None else parsed).date()
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -454,6 +472,26 @@ def fleet_due_without_sensor(hass: HomeAssistant) -> bool:
|
||||
return _fleet_object(hass).get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is not False
|
||||
|
||||
|
||||
def device_model_key(hass: HomeAssistant, device_id: str | None) -> str:
|
||||
""""manufacturer|model" (lowercased) for a device registry id, "" when
|
||||
unknown — the learning pool key (see battery_lifetime)."""
|
||||
if not device_id:
|
||||
return ""
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device is None:
|
||||
return ""
|
||||
# HA 2026.9 may hand back a ChildDeviceEntry, which has no make/model.
|
||||
manufacturer = str(getattr(device, "manufacturer", None) or "").strip().lower()
|
||||
model = str(getattr(device, "model", None) or getattr(device, "model_id", None) or "").strip().lower()
|
||||
if not model or not manufacturer:
|
||||
# A generic model without a maker ("Door Sensor") would pool
|
||||
# unrelated devices from different integrations.
|
||||
return ""
|
||||
return f"{manufacturer}|{model}"
|
||||
|
||||
|
||||
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
|
||||
"""Whether a device recharges itself — its battery is never REPLACED.
|
||||
|
||||
@@ -675,7 +713,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
entity_id=state.entity_id,
|
||||
device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
|
||||
battery_type=str(attrs.get("battery_type") or "Unknown"),
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
quantity=_quantity_of(attrs),
|
||||
model_key=device_model_key(hass, dev_id),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=last_replaced,
|
||||
@@ -779,6 +818,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
device_name=name or rec["eid"],
|
||||
battery_type="Rechargeable" if rec.get("self_charging") else "Unknown",
|
||||
quantity=1,
|
||||
model_key=device_model_key(hass, rec.get("device_id")),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=None,
|
||||
@@ -800,6 +840,11 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
# reading (a plus of any kind, a native row) wins the device.
|
||||
due_without_sensor = fleet_due_without_sensor(hass)
|
||||
today = dt_util.now().date()
|
||||
# Bug audit 2026-09-12: the "due" decision below must use the SAME lifetime
|
||||
# the roster forecasts with (override > learned > table) - it used the bare
|
||||
# table, so an override or a learned value moved the roster's ~date while
|
||||
# the task kept firing (or never fired) on the table's.
|
||||
lifetime_for = lifetime_resolver(hass)
|
||||
native_devices = {rec["device_id"] for rec in native.values() if rec["device_id"]}
|
||||
for state in hass.states.async_all("sensor"):
|
||||
if not _is_type_note(state):
|
||||
@@ -853,7 +898,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
entity_id=eid,
|
||||
device_name=name,
|
||||
battery_type=battery_type,
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
quantity=_quantity_of(attrs),
|
||||
model_key=device_model_key(hass, dev_id),
|
||||
low=False,
|
||||
level=None,
|
||||
last_replaced=last_replaced,
|
||||
@@ -864,8 +910,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
)
|
||||
# Due = the forecast has PASSED (same arithmetic as build_overview's
|
||||
# forecast_overdue). Rechargeables never get a table forecast.
|
||||
if due_without_sensor and not is_rechargeable_type(battery_type):
|
||||
pred = _predicted_date(bat)
|
||||
if due_without_sensor and is_shoppable_type(battery_type):
|
||||
pred = _predicted_date(bat, lifetime_for(bat).months)
|
||||
bat.low = pred is not None and pred < today
|
||||
out.append(bat)
|
||||
for bat in out:
|
||||
@@ -973,7 +1019,9 @@ def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON
|
||||
:func:`async_compute_overview` instead.
|
||||
"""
|
||||
today = dt_util.now().date()
|
||||
return build_overview(read_batteries(hass), today=today, horizon_days=horizon_days)
|
||||
batteries = read_batteries(hass)
|
||||
observe_replacements(hass, batteries)
|
||||
return build_overview(batteries, today=today, horizon_days=horizon_days, lifetime_for=lifetime_resolver(hass))
|
||||
|
||||
|
||||
# ── discharge-trend forecast (#114 follow-up) ───────────────────────────────
|
||||
@@ -1048,8 +1096,11 @@ async def async_trend_predictions(hass: HomeAssistant, batteries: list[Battery])
|
||||
async def async_compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
|
||||
"""Read + trend-enrich + aggregate (the panel's entry point)."""
|
||||
batteries = read_batteries(hass)
|
||||
observe_replacements(hass, batteries)
|
||||
trends = await async_trend_predictions(hass, batteries)
|
||||
return build_overview(batteries, today=dt_util.now().date(), horizon_days=horizon_days, trend_predictions=trends)
|
||||
return build_overview(
|
||||
batteries, today=dt_util.now().date(), horizon_days=horizon_days, trend_predictions=trends, lifetime_for=lifetime_resolver(hass)
|
||||
)
|
||||
|
||||
|
||||
# ── level history for the roster sparklines ────────────────────────────────
|
||||
@@ -1188,11 +1239,12 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
"""
|
||||
totals: OrderedDict[str, int] = OrderedDict()
|
||||
for bat in read_batteries(hass):
|
||||
if is_rechargeable_type(bat.battery_type):
|
||||
continue
|
||||
t = _norm_type(bat.battery_type)
|
||||
if t == "UNKNOWN":
|
||||
# "Irreplaceable" / "Manual" / "Solar" describe the device, not a
|
||||
# cell anyone stocks - no part, no reorder threshold. Same predicate
|
||||
# as the overview's shopping groupings (is_shoppable_type).
|
||||
if not is_shoppable_type(bat.battery_type):
|
||||
continue
|
||||
t = canonical_type(bat.battery_type)
|
||||
totals[t] = totals.get(t, 0) + bat.quantity
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
@@ -1208,6 +1260,7 @@ __all__ = [
|
||||
"async_trend_predictions",
|
||||
"battery_notes_summary",
|
||||
"build_overview",
|
||||
"canonical_type",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
"fleet_due_without_sensor",
|
||||
|
||||
Reference in new issue
Block a user