124 files

This commit is contained in:
Home Assistant Version Control
2026-08-31 15:02:40 +00:00
parent 33de328a33
commit 9742f385a3
124 changed files with 13907 additions and 1376 deletions
@@ -445,6 +445,10 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
self-charging filter is off entirely and such devices surface as
rechargeables (native rows typed "Rechargeable", never "Unknown").
"""
# #146: the fleet-wide low floor is a household setting (default 20 %).
from .global_options import get_battery_low_percent
floor = float(get_battery_low_percent(hass))
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
@@ -525,7 +529,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# 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)
low = bool(attrs.get("battery_low")) or (level is not None and level <= floor)
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
@@ -575,7 +579,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
last_replaced=last_replaced,
available=available,
source="battery_notes",
low_threshold=_note_low_threshold(attrs),
low_threshold=_note_low_threshold(attrs, floor),
)
)
@@ -648,7 +652,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
if low_state is not None:
low = low_available and str(low_state).lower() in ("on", "true", "1")
else:
low = level is not None and level <= NATIVE_LOW_PERCENT
low = level is not None and level <= floor
if available:
# Remember the last real reading — the retention path below needs
# it once the entity goes unavailable.
@@ -683,6 +687,61 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
return out
def battery_notes_summary(hass: HomeAssistant) -> dict[str, Any] | None:
"""What Battery Notes currently reports, for the Settings hint (#146).
Derived purely from the ``battery_low_threshold`` attribute the
``battery_plus`` entities expose — no reach into Battery Notes' own
configuration. The most common value is their default; devices whose
value differs are overrides, named (highest first, capped at 5 so a
large install cannot spam the settings page) and linked via their
device id when the registry knows one. ``None`` when Battery Notes is
not present.
"""
from collections import Counter
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)
per_device: dict[str, dict[str, Any]] = {}
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
raw = state.attributes.get("battery_low_threshold")
if not isinstance(raw, (int, float)) or isinstance(raw, bool):
continue
reg = ent_reg.async_get(state.entity_id)
device_id = reg.device_id if reg else None
name = state.attributes.get("device_name")
if device_id and (device := dev_reg.async_get(device_id)):
name = device.name_by_user or device.name or name
key = device_id or state.entity_id
row = per_device.get(key)
threshold = float(raw)
if row is None or threshold > row["threshold"]:
per_device[key] = {
"name": str(name or state.entity_id),
"device_id": device_id,
"threshold": threshold,
}
if not per_device:
return None
counts = Counter(row["threshold"] for row in per_device.values())
default = counts.most_common(1)[0][0]
overrides = sorted(
(row for row in per_device.values() if row["threshold"] != default),
key=lambda r: (-r["threshold"], r["name"].lower()),
)
return {
"default": default,
"devices": len(per_device),
"overrides": overrides[:5],
"more": max(0, len(overrides) - 5),
}
def has_battery_notes(hass: HomeAssistant) -> bool:
"""Whether the Battery Notes integration is present (any battery_plus).
@@ -851,12 +910,16 @@ def _detect_unrecorded_jump(
return None
def _note_low_threshold(attrs: dict[str, Any]) -> float:
"""The Battery-Notes-configured threshold OR the fleet floor — the higher."""
def _note_low_threshold(attrs: dict[str, Any], floor: float = float(NATIVE_LOW_PERCENT)) -> float:
"""The Battery-Notes-configured threshold OR the fleet floor — the higher.
``floor`` is the household's battery_low_percent (#146); the module
constant only remains as the fallback default.
"""
raw = attrs.get("battery_low_threshold")
if isinstance(raw, (int, float)):
return float(max(raw, NATIVE_LOW_PERCENT))
return float(NATIVE_LOW_PERCENT)
return float(max(raw, floor))
return float(floor)
async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) -> dict[str, dict[str, Any]]:
@@ -941,6 +1004,7 @@ __all__ = [
"async_compute_overview",
"async_level_history",
"async_trend_predictions",
"battery_notes_summary",
"build_overview",
"compute_overview",
"discover_battery_types",
@@ -492,6 +492,53 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: s
return True
async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> dict[str, Any]:
"""Boot-time reconcile of the fleet's type-parts (issue #148).
A battery whose TYPE only becomes known after the fleet was set up (a
Battery Notes note added later, or a low-only Matter lock whose typed
``…_battery_plus_low`` binary arrived with #121) never got its spare
part: ``_reconcile_type_parts`` only ran on an explicit fleet-setup
call. Runs the same reconcile once per start — and prunes the legacy
"UNKNOWN battery" part older versions minted for untyped batteries,
but ONLY while it is completely untouched (no stock counted, no
product/vendor data, auto-buy off); the moment a user touched it, it
is theirs and stays.
"""
types = discover_battery_types(hass)
added = _reconcile_type_parts(hass, entry, types, lang)
pruned = False
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
parts = dict(entry.data.get(CONF_PARTS) or {})
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:
parts.pop("batt_unknown")
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
hass.config_entries.async_update_entry(entry, data=new_data)
pruned = True
if store is not None and (added or pruned):
for pid in added:
store.set_part_stock(pid, 0)
await store.async_save()
return {"added": added, "pruned": pruned}
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)."""
@@ -19,13 +19,18 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_BATTERY_LOW_PERCENT,
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
CONF_DEFAULT_WARNING_DAYS,
CONF_PANEL_TITLE,
DEFAULT_BATTERY_LOW_PERCENT,
DEFAULT_CONSUMABLE_THRESHOLD,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_PANEL_TITLE_LENGTH,
PANEL_TITLE,
THRESHOLD_PERCENT_RANGE,
)
@@ -89,3 +94,27 @@ def get_panel_title(hass: HomeAssistant) -> str:
if not title:
return PANEL_TITLE
return title[:MAX_PANEL_TITLE_LENGTH]
def _percent_option(hass: HomeAssistant, key: str, default: int) -> int:
"""A percent option within THRESHOLD_PERCENT_RANGE, else its default."""
raw = get_global_options(hass).get(key, default)
try:
value = int(raw)
except (TypeError, ValueError):
return default
lo, hi = THRESHOLD_PERCENT_RANGE
return value if lo <= value <= hi else default
def get_consumable_threshold(hass: HomeAssistant) -> int:
"""#146: the household's floor for percent-remaining consumables — what
Suggested setups pre-wires for catalog signatures that use the default
(a signature with its own explicit floor keeps it)."""
return _percent_option(hass, CONF_DEFAULT_CONSUMABLE_THRESHOLD, DEFAULT_CONSUMABLE_THRESHOLD)
def get_battery_low_percent(hass: HomeAssistant) -> int:
"""#146: the fleet-wide "battery low" floor for batteries without their
own Battery Notes threshold (a higher Battery Notes threshold still wins)."""
return _percent_option(hass, CONF_BATTERY_LOW_PERCENT, DEFAULT_BATTERY_LOW_PERCENT)
@@ -34,11 +34,13 @@ from ..const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BATTERY_LOW_PERCENT,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
@@ -64,6 +66,8 @@ from ..const import (
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_REMINDER_LEAD_DAYS,
CONF_ROW_ACTION_NOTICE,
CONF_ROW_ACTION_STYLE,
CONF_SHOPPING_LIST_ENTITY,
CONF_SNOOZE_DURATION_HOURS,
CONF_WARRANTY_REMINDER_DAYS,
@@ -90,12 +94,19 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
# 0 = no warning window: new tasks turn due_soon on the due date itself
# (the per-task range already allowed 0 — #145).
SettingSpec(CONF_DEFAULT_WARNING_DAYS, int, int_range=(0, 365)),
# #146: household "low" floors (percent) for discovery and the battery fleet.
SettingSpec(CONF_DEFAULT_CONSUMABLE_THRESHOLD, int, int_range=(1, 90)),
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, int_range=(1, 90)),
SettingSpec(CONF_NOTIFICATIONS_ENABLED, bool),
SettingSpec(CONF_NOTIFY_SERVICE, str, max_len=200),
# todo.* entity the buy-task shopping sync mirrors into ("" = off);
# format-validated by a bespoke sanitiser rule (todo. prefix), max_len is
# HA's entity-id cap.
SettingSpec(CONF_SHOPPING_LIST_ENTITY, str, max_len=255),
# #145: enum-validated by a bespoke rule (ROW_ACTION_STYLES); the notice
# flag is only ever cleared by the panel banner.
SettingSpec(CONF_ROW_ACTION_STYLE, str, max_len=32),
SettingSpec(CONF_ROW_ACTION_NOTICE, bool),
SettingSpec(CONF_PANEL_ENABLED, bool),
# panel_title is trimmed+capped to MAX_PANEL_TITLE_LENGTH by a bespoke rule,
# not a plain drop-if-too-long — so no max_len here.
@@ -245,6 +245,12 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
# entity's own unit — no conversion in either case.
return float(sig.delta_units)
if sig.direction == "percent_left":
# #146: catalog-default floors follow the household setting; a
# signature that pins its own floor keeps it.
if sig.below_percent == _DEFAULT_BELOW_PERCENT:
from ..global_options import get_consumable_threshold
return float(get_consumable_threshold(hass))
return float(sig.below_percent)
state = hass.states.get(entity_id)
unit = (state.attributes.get("unit_of_measurement") if state else None) or "h"