124 files
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user