224 files
This commit is contained in:
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -61,6 +61,41 @@ NATIVE_LOW_PERCENT = 20
|
||||
# offline — that's exactly the one you must not hide).
|
||||
_NO_READING = {"unavailable", "unknown", "none", ""}
|
||||
|
||||
# How long a NATIVE battery that was last seen LOW stays in the fleet after
|
||||
# its entity goes unavailable. Battery Notes covers this case via its retained
|
||||
# ``battery_low`` attribute; native entities have no equivalent, so without a
|
||||
# snapshot the battery would vanish at the exact moment it died and took its
|
||||
# device offline. Bounded so a permanently removed device eventually drops.
|
||||
_NATIVE_RETENTION = timedelta(hours=48)
|
||||
|
||||
# Heuristic (sensors WITHOUT device_class): a %-sensor whose object_id talks
|
||||
# about a battery — some Zigbee2MQTT/ESPHome devices ship battery levels
|
||||
# without the device class. Deliberately strict: the exclusion words keep out
|
||||
# charging electronics and home-storage state-of-charge sensors (a Powerwall
|
||||
# is not a battery you replace).
|
||||
_HEURISTIC_EXCLUDE = ("charging", "current", "power", "voltage", "energy", "load", "soc", "state_of_charge", "storage", "temp")
|
||||
|
||||
|
||||
def _is_native_battery_sensor(state: Any) -> bool:
|
||||
"""Whether a sensor state looks like a replaceable-battery level."""
|
||||
attrs = state.attributes
|
||||
if attrs.get("device_class") == "battery":
|
||||
return True
|
||||
if attrs.get("unit_of_measurement") != "%":
|
||||
return False
|
||||
object_id = state.entity_id.split(".", 1)[1]
|
||||
if "battery" not in object_id:
|
||||
return False
|
||||
return not any(word in object_id for word in _HEURISTIC_EXCLUDE)
|
||||
|
||||
|
||||
def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
|
||||
"""Runtime cache of last-known native battery readings (per entity_id)."""
|
||||
from ..const import DOMAIN
|
||||
|
||||
cache: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}).setdefault("battery_fleet_native_cache", {})
|
||||
return cache
|
||||
|
||||
|
||||
def _norm_type(raw: Any) -> str:
|
||||
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
|
||||
@@ -188,6 +223,46 @@ def _level_of(state_val: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
|
||||
"""Manually excluded battery entity_ids, stored on the fleet object entry.
|
||||
|
||||
Inlined lookup (not via battery_fleet_setup.find_fleet_entry) to keep this
|
||||
module import-cycle-free — setup imports the aggregation, not vice versa.
|
||||
"""
|
||||
from ..const import CONF_OBJECT, DOMAIN
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
if obj.get("battery_fleet"):
|
||||
return set(obj.get("battery_fleet_excluded") or [])
|
||||
return set()
|
||||
|
||||
|
||||
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
|
||||
"""Whether a device recharges itself — its battery is never REPLACED.
|
||||
|
||||
Issue #107: a Roborock's native battery sensor reads "low" mid-clean, but
|
||||
nobody swaps its cells. Heuristics (native pickup only — an explicit
|
||||
Battery Notes note always wins): the device also has a vacuum/lawn_mower
|
||||
entity, exposes a ``battery_charging`` binary, or is a Companion-app
|
||||
phone/tablet (``mobile_app`` identifiers).
|
||||
"""
|
||||
if not device_id:
|
||||
return False
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device and any(domain == "mobile_app" for domain, _ in device.identifiers):
|
||||
return True
|
||||
for reg_entry in er.async_entries_for_device(er.async_get(hass), device_id, include_disabled_entities=True):
|
||||
if reg_entry.domain in ("vacuum", "lawn_mower"):
|
||||
return True
|
||||
if reg_entry.domain == "binary_sensor" and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
"""Read the battery fleet from HA state — Battery Notes AND native.
|
||||
|
||||
@@ -197,16 +272,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
|
||||
dead battery that took its device offline stays visible.
|
||||
* **Native** ``device_class: battery`` entities (a %-sensor and/or a
|
||||
battery-low binary), grouped per device, give a degraded view (type
|
||||
"Unknown", quantity 1, no forecast). A device already covered by a
|
||||
Battery Notes note is skipped (dedup by the note's source entity + its
|
||||
device) so it isn't counted twice.
|
||||
battery-low binary) — plus %-sensors matching the strict battery-name
|
||||
heuristic for devices that ship no device class — grouped per device,
|
||||
give a degraded view (type "Unknown", quantity 1, no forecast). A
|
||||
device already covered by a Battery Notes note is skipped (dedup by
|
||||
the note's source entity + its device) so it isn't counted twice;
|
||||
self-charging devices (vacuums, mowers, phones — see
|
||||
:func:`_is_self_charging`) are skipped entirely. A native battery
|
||||
last seen LOW that goes unavailable is retained from a runtime
|
||||
snapshot for ``_NATIVE_RETENTION`` (the Battery Notes path gets this
|
||||
for free via its retained ``battery_low`` attribute).
|
||||
* Manually excluded entity_ids (fleet detail → exclude) are dropped from
|
||||
BOTH passes.
|
||||
"""
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
excluded = fleet_excluded_entities(hass)
|
||||
|
||||
out: list[Battery] = []
|
||||
covered_sources: set[str] = set()
|
||||
@@ -217,17 +301,36 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
attrs = state.attributes
|
||||
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
|
||||
continue
|
||||
level = _level_of(state.state)
|
||||
available = state.state not in _NO_READING and level is not None
|
||||
# B2 (roadmap 2026-07-22 audit): ONE low floor across both passes.
|
||||
# Battery Notes' own threshold (default 10 %) still counts via its
|
||||
# battery_low flag, but the fleet-wide NATIVE_LOW_PERCENT floor is
|
||||
# OR-ed in — a CR2032 at 11.5 % was "healthy" here while the same
|
||||
# level counted low in the native pass. A HIGHER Battery Notes
|
||||
# threshold (e.g. 30 %) still wins through battery_low.
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
|
||||
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
|
||||
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
|
||||
# sensor, so the state reads unknown forever — must SURVIVE when it
|
||||
# carries a replacement date: that date is all `_predicted_date`
|
||||
# needs, and dropping these hid 11 overdue batteries in a live fleet.
|
||||
# Offline AND not low AND no date = pure connectivity noise → drop.
|
||||
if not available and not low and last_replaced is None:
|
||||
continue
|
||||
# B3: only a KEPT note covers its source/device — a dropped dead note
|
||||
# must not suppress the native fallback for its own device (a device
|
||||
# with a dead note and a working level sensor was invisible in BOTH
|
||||
# passes).
|
||||
src = attrs.get("source_entity_id")
|
||||
if src:
|
||||
covered_sources.add(src)
|
||||
reg = ent_reg.async_get(state.entity_id)
|
||||
if reg and reg.device_id:
|
||||
covered_devices.add(reg.device_id)
|
||||
level = _level_of(state.state)
|
||||
available = state.state not in _NO_READING and level is not None
|
||||
low = bool(attrs.get("battery_low"))
|
||||
# Offline AND not last-known-low = pure connectivity noise → drop it.
|
||||
if not available and not low:
|
||||
# An EXCLUDED note still covers (above): exclusion hides the battery —
|
||||
# it must not resurrect as a degraded native "Unknown" row.
|
||||
if state.entity_id in excluded:
|
||||
continue
|
||||
out.append(
|
||||
Battery(
|
||||
@@ -237,7 +340,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=_parse_last_replaced(attrs.get("battery_last_replaced")),
|
||||
last_replaced=last_replaced,
|
||||
available=available,
|
||||
source="battery_notes",
|
||||
)
|
||||
@@ -248,17 +351,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
native: dict[str, dict[str, Any]] = {}
|
||||
for domain in ("sensor", "binary_sensor"):
|
||||
for state in hass.states.async_all(domain):
|
||||
if state.attributes.get("device_class") != "battery":
|
||||
# Sensors: device_class battery OR the strict name/% heuristic
|
||||
# (Zigbee2MQTT/ESPHome levels without a device class). Binaries:
|
||||
# device_class only — name-guessing booleans is too risky.
|
||||
if domain == "sensor":
|
||||
if not _is_native_battery_sensor(state):
|
||||
continue
|
||||
elif state.attributes.get("device_class") != "battery":
|
||||
continue
|
||||
eid = state.entity_id
|
||||
if "battery_type" in state.attributes: # Battery Notes battery_plus — handled above
|
||||
continue
|
||||
if eid in covered_sources:
|
||||
if eid in covered_sources or eid in excluded:
|
||||
continue
|
||||
reg = ent_reg.async_get(eid)
|
||||
dev_id = reg.device_id if reg else None
|
||||
if dev_id and dev_id in covered_devices:
|
||||
continue
|
||||
if _is_self_charging(hass, dev_id): # #107: vacuums/mowers/phones
|
||||
continue
|
||||
key = dev_id or eid
|
||||
rec = native.setdefault(
|
||||
key,
|
||||
@@ -273,6 +384,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
if rec["name"] is None and friendly:
|
||||
rec["name"] = friendly
|
||||
|
||||
snapshot_cache = _native_snapshot_cache(hass)
|
||||
now = dt_util.utcnow()
|
||||
for rec in native.values():
|
||||
level = _level_of(rec["level_state"]) if rec["level_state"] is not None else None
|
||||
low_state = rec["low_state"]
|
||||
@@ -283,6 +396,19 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
low = low_available and str(low_state).lower() in ("on", "true", "1")
|
||||
else:
|
||||
low = level is not None and level <= NATIVE_LOW_PERCENT
|
||||
if available:
|
||||
# Remember the last real reading — the retention path below needs
|
||||
# it once the entity goes unavailable.
|
||||
snapshot_cache[rec["eid"]] = {"low": low, "level": level, "ts": now}
|
||||
elif not low:
|
||||
# Native dead-battery retention: an entity that was LOW and then
|
||||
# went unavailable (the battery died and took the device offline)
|
||||
# stays visible for _NATIVE_RETENTION instead of vanishing at the
|
||||
# exact moment it needs replacing.
|
||||
snap = snapshot_cache.get(rec["eid"])
|
||||
if snap and snap.get("low") and now - snap["ts"] <= _NATIVE_RETENTION:
|
||||
low = True
|
||||
level = snap.get("level")
|
||||
if not available and not low:
|
||||
continue
|
||||
name = rec["name"]
|
||||
@@ -315,11 +441,9 @@ def has_battery_notes(hass: HomeAssistant) -> bool:
|
||||
|
||||
def has_batteries(hass: HomeAssistant) -> bool:
|
||||
"""Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
|
||||
for domain in ("sensor", "binary_sensor"):
|
||||
for state in hass.states.async_all(domain):
|
||||
if state.attributes.get("device_class") == "battery":
|
||||
return True
|
||||
return False
|
||||
if any(_is_native_battery_sensor(s) for s in hass.states.async_all("sensor")):
|
||||
return True
|
||||
return any(s.attributes.get("device_class") == "battery" for s in hass.states.async_all("binary_sensor"))
|
||||
|
||||
|
||||
def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
|
||||
@@ -346,6 +470,7 @@ __all__ = [
|
||||
"build_overview",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
"fleet_excluded_entities",
|
||||
"has_batteries",
|
||||
"has_battery_notes",
|
||||
"lifetime_months",
|
||||
|
||||
Reference in New Issue
Block a user