197 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-21 18:53:37 +00:00
1 parent 89d9f5e49b
commit 383e9ed04e
197 files changed
+4386 -1837

No files matched your search

@@ -31,7 +31,7 @@ import re
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
from typing import Any, cast
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@@ -322,33 +322,53 @@ 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.
def _fleet_object(hass: HomeAssistant) -> dict[str, Any]:
"""The fleet object dict from its config entry, or ``{}`` when no fleet.
Inlined lookup (not via battery_fleet_setup.find_fleet_entry) to keep this
module import-cycle-free — setup imports the aggregation, not vice versa.
Inlined entry loop (not via battery_fleet_setup.find_fleet_entry) to keep
this module import-cycle-free — setup imports the aggregation, not vice
versa. The three getters below were three copies of this loop (drift
audit 2026-08).
"""
from ..const import BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_OBJECT_FLAG, CONF_OBJECT, DOMAIN
from ..const import BATTERY_FLEET_OBJECT_FLAG, CONF_OBJECT, DOMAIN
for entry in hass.config_entries.async_entries(DOMAIN):
obj = entry.data.get(CONF_OBJECT, {})
if obj.get(BATTERY_FLEET_OBJECT_FLAG):
return set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
return set()
return cast(dict[str, Any], obj)
return {}
def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
"""Manually excluded battery entity_ids, stored on the fleet object entry."""
from ..const import BATTERY_FLEET_EXCLUDED
return set(_fleet_object(hass).get(BATTERY_FLEET_EXCLUDED) or [])
def fleet_included_entities(hass: HomeAssistant) -> set[str]:
"""Manually ADDED battery entity_ids (#135) — same storage pattern as
the exclusions. An include bypasses the discovery heuristic and the
self-charging filter in the native pass; Battery-Notes coverage and the
exclusion list still win (no duplicate rows, exclusion stays king)."""
from ..const import BATTERY_FLEET_INCLUDED, BATTERY_FLEET_OBJECT_FLAG, CONF_OBJECT, DOMAIN
self-charging filter in BOTH passes (lurisin's Oura Ring carried a
Battery Notes note, so the native-pass-only bypass never fired);
Battery-Notes coverage and the exclusion list still win (no duplicate
rows, exclusion stays king)."""
from ..const import BATTERY_FLEET_INCLUDED
for entry in hass.config_entries.async_entries(DOMAIN):
obj = entry.data.get(CONF_OBJECT, {})
if obj.get(BATTERY_FLEET_OBJECT_FLAG):
return set(obj.get(BATTERY_FLEET_INCLUDED) or [])
return set()
return set(_fleet_object(hass).get(BATTERY_FLEET_INCLUDED) or [])
def fleet_track_self_charging(hass: HomeAssistant) -> bool:
"""Whether the fleet keeps self-charging devices in the roster (#135).
Off (the default) preserves #107: vacuums/phones/rings never enter. On,
they appear as rechargeables — typed "Rechargeable", labelled
"— recharge", never counted into the shopping needs — so a low phone or
smart ring can drive a "charge it" notification. Exclusions still win.
"""
from ..const import BATTERY_FLEET_TRACK_SELF_CHARGING
return bool(_fleet_object(hass).get(BATTERY_FLEET_TRACK_SELF_CHARGING))
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
@@ -418,6 +438,12 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
for free via its retained ``battery_low`` attribute).
* Manually excluded entity_ids (fleet detail → exclude) are dropped from
BOTH passes.
* Manual includes (#135) act DEVICE-wide: whichever of a device's battery
entities the user picked, the self-charging filter is lifted for the
whole device in both passes — so the richest source (a Battery Notes
note) still wins the row. With ``fleet_track_self_charging`` on, the
self-charging filter is off entirely and such devices surface as
rechargeables (native rows typed "Rechargeable", never "Unknown").
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
@@ -426,6 +452,24 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
dev_reg = dr.async_get(hass)
excluded = fleet_excluded_entities(hass)
included = fleet_included_entities(hass)
track_self = fleet_track_self_charging(hass)
# The include acts device-wide (see docstring) — resolve each included
# entity to its device once.
included_devices: set[str] = set()
for _eid in included:
_reg = ent_reg.async_get(_eid)
if _reg and _reg.device_id:
included_devices.add(_reg.device_id)
# _is_self_charging walks the device's registry entries — cache per device
# (a device's %-sensor and low-binary would otherwise both pay for it).
_self_charging_cache: dict[str, bool] = {}
def _self_charging(dev_id: str | None) -> bool:
if not dev_id:
return False
if dev_id not in _self_charging_cache:
_self_charging_cache[dev_id] = _is_self_charging(hass, dev_id)
return _self_charging_cache[dev_id]
out: list[Battery] = []
covered_sources: set[str] = set()
@@ -487,8 +531,10 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# 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:
# Offline AND not low AND no date = pure connectivity noise → drop —
# unless the user explicitly included THIS entity (#135): a stated
# battery stays visible even while it reads like noise.
if not available and not low and last_replaced is None and state.entity_id not in included:
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
@@ -506,8 +552,17 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# #107 follow-up: the skip covers noted devices too (it covers
# above for the same reason exclusion does). Battery Notes
# auto-discovers vacuums/phones from its library, so a note is
# not evidence anyone means to swap cells there.
if _is_self_charging(hass, dev_id):
# not evidence anyone means to swap cells there. A manual include
# (this entity or a sibling on the same device, #135) or the
# track-self-charging option lifts the skip — lurisin's Oura Ring
# was noted AND self-charging, and the v2.61.0 bypass lived only
# in the native pass, so including it did nothing.
if (
not track_self
and state.entity_id not in included
and dev_id not in included_devices
and _self_charging(dev_id)
):
continue
out.append(
Battery(
@@ -550,12 +605,28 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
dev_id = reg.device_id if reg else None
if dev_id and dev_id in covered_devices:
continue
if eid not in included and _is_self_charging(hass, dev_id): # #107
self_charging = _self_charging(dev_id)
if ( # #107; lifted by an include on the device (#135) or the option
self_charging
and not track_self
and eid not in included
and dev_id not in included_devices
):
continue
key = dev_id or eid
rec = native.setdefault(
key,
{"level_state": None, "low_state": None, "device_id": dev_id, "eid": eid, "name": None},
{
"level_state": None,
"low_state": None,
"device_id": dev_id,
"eid": eid,
"name": None,
# A surfaced self-charging device is a RECHARGEABLE, not an
# "Unknown" cell — the type drives the "— recharge" label
# and keeps it out of the shopping needs.
"self_charging": self_charging,
},
)
friendly = state.attributes.get("friendly_name")
if domain == "sensor":
@@ -600,7 +671,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
Battery(
entity_id=rec["eid"],
device_name=name or rec["eid"],
battery_type="Unknown",
battery_type="Rechargeable" if rec.get("self_charging") else "Unknown",
quantity=1,
low=low,
level=level,
@@ -875,6 +946,7 @@ __all__ = [
"discover_battery_types",
"fleet_excluded_entities",
"fleet_included_entities",
"fleet_track_self_charging",
"has_batteries",
"has_battery_notes",
"is_rechargeable_type",
@@ -10,6 +10,7 @@ threshold trigger. No per-battery task.
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import uuid4
@@ -22,6 +23,7 @@ from ..const import (
BATTERY_FLEET_EXCLUDED,
BATTERY_FLEET_INCLUDED,
BATTERY_FLEET_OBJECT_FLAG,
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_PARTS,
CONF_TASKS,
@@ -32,10 +34,16 @@ from .battery_fleet import _norm_type, discover_battery_types, lifetime_months,
# The global aggregate sensor the fleet task triggers on (fixed entity_id).
LOW_COUNT_ENTITY_ID = "sensor.maintenance_supporter_batteries_to_replace"
# Hygiene bound on the manual exclude/include lists (security review
# 2026-08-21): both are write-gated, but nothing else limited their growth —
# a scripted operator could bloat the fleet object's config entry without
# bound. 2000 is far above any real fleet.
FLEET_LIST_CAP = 2000
# Marker on the object + task so the panel renders the battery detail section
# and setup is idempotent (never a second fleet).
OBJECT_FLAG = BATTERY_FLEET_OBJECT_FLAG
TASK_FLAG = "battery_fleet_task"
TASK_FLAG = BATTERY_FLEET_TASK_FLAG
def find_fleet_entry(hass: HomeAssistant) -> ConfigEntry | None:
@@ -342,6 +350,21 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
return {"marked": len(targets), "pressed": pressed, "consumed": consumed}
def _mutate_fleet_object(hass: HomeAssistant, mutate: Callable[[dict[str, Any]], None]) -> bool:
"""Find the fleet entry, apply ``mutate`` to a copy of its object dict,
persist. Returns False when no fleet exists yet — the shared scaffolding
the three setters below each hand-rolled (drift audit 2026-08)."""
entry = find_fleet_entry(hass)
if entry is None:
return False
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
mutate(obj)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
return True
def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) -> bool:
"""Persist a manual exclude/include of one battery on the fleet object.
@@ -350,28 +373,25 @@ def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) ->
``battery_fleet_excluded`` on the fleet object dict. Returns False when
no fleet exists yet.
"""
entry = find_fleet_entry(hass)
if entry is None:
return False
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
current = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
if excluded:
# Symmetry (#135): hiding a manually-added battery removes the manual
# include instead of stacking an exclusion on top of it.
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
if entity_id in includes:
includes.discard(entity_id)
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
else:
current.add(entity_id)
else:
current.discard(entity_id)
obj[BATTERY_FLEET_EXCLUDED] = sorted(current)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
return True
def _apply(obj: dict[str, Any]) -> None:
current = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
if excluded:
# Symmetry (#135): hiding a manually-added battery removes the
# manual include instead of stacking an exclusion on top of it.
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
if entity_id in includes:
includes.discard(entity_id)
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
else:
if len(current) >= FLEET_LIST_CAP and entity_id not in current:
raise HomeAssistantError(f"Battery fleet exclusion list is full ({FLEET_LIST_CAP})")
current.add(entity_id)
else:
current.discard(entity_id)
obj[BATTERY_FLEET_EXCLUDED] = sorted(current)
return _mutate_fleet_object(hass, _apply)
def set_battery_included(hass: HomeAssistant, entity_id: str, included: bool) -> bool:
@@ -382,24 +402,34 @@ def set_battery_included(hass: HomeAssistant, entity_id: str, included: bool) ->
a previous exclusion (the user changed their mind). Returns False when no
fleet exists yet.
"""
entry = find_fleet_entry(hass)
if entry is None:
return False
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
if included:
includes.add(entity_id)
excludes = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
if entity_id in excludes:
excludes.discard(entity_id)
obj[BATTERY_FLEET_EXCLUDED] = sorted(excludes)
else:
includes.discard(entity_id)
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
return True
def _apply(obj: dict[str, Any]) -> None:
includes = set(obj.get(BATTERY_FLEET_INCLUDED) or [])
if included:
if len(includes) >= FLEET_LIST_CAP and entity_id not in includes:
raise HomeAssistantError(f"Battery fleet include list is full ({FLEET_LIST_CAP})")
includes.add(entity_id)
excludes = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
if entity_id in excludes:
excludes.discard(entity_id)
obj[BATTERY_FLEET_EXCLUDED] = sorted(excludes)
else:
includes.discard(entity_id)
obj[BATTERY_FLEET_INCLUDED] = sorted(includes)
return _mutate_fleet_object(hass, _apply)
def set_track_self_charging(hass: HomeAssistant, enabled: bool) -> bool:
"""Persist the fleet-wide track-self-charging opt-in (#135 follow-up).
Stored on the fleet object like the exclude/include lists. Returns False
when no fleet exists yet.
"""
from ..const import BATTERY_FLEET_TRACK_SELF_CHARGING
return _mutate_fleet_object(hass, lambda obj: obj.__setitem__(BATTERY_FLEET_TRACK_SELF_CHARGING, bool(enabled)))
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
@@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_TASK_PRIORITY,
DEFAULT_WARNING_DAYS,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
@@ -130,7 +131,7 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
"custom_icon": _csv_safe(tdata.get("custom_icon", "")),
"nfc_tag_id": _csv_safe(tdata.get("nfc_tag_id", "")),
"responsible_user_id": _csv_safe(tdata.get("responsible_user_id", "")),
"priority": tdata.get("priority") or "normal",
"priority": tdata.get("priority") or DEFAULT_TASK_PRIORITY,
# Labels ride one cell, "\n"-separated like the checklist.
"labels": "\n".join(_csv_safe(lb) for lb in (tdata.get("labels") or []) if lb),
"trigger_type": (tdata.get("trigger_config") or {}).get("type", ""),
@@ -10,7 +10,7 @@ from __future__ import annotations
import calendar
from collections.abc import Iterator
from datetime import date, timedelta
from datetime import UTC, date, datetime, timedelta
from .workday import is_business_day
@@ -27,6 +27,27 @@ def parse_iso_date(value: str | None) -> date | None:
return None
def parse_persisted_utc(value: str | None) -> datetime | None:
"""Persisted ISO timestamp → TZ-aware ``datetime``; ``None`` when absent
or malformed.
Live writes use ``dt_util.utcnow().isoformat()`` (TZ-aware), but older
payloads may be naive — those are assumed UTC, because subtracting a naive
datetime from ``dt_util.utcnow()`` raises ``TypeError``. One home for the
block the threshold and state-change triggers each hand-rolled (and the
runtime trigger FORGOT — a naive ``on_since`` crashed its elapsed math).
"""
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def _add_months(anchor: date, months: int) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day."""
total = anchor.month - 1 + months
@@ -239,20 +239,28 @@ def pop_stashed_config(hass: HomeAssistant, entity_id: str) -> dict[str, Any] |
return None
def build_problem_task(entity_id: str, name: str) -> dict[str, Any]:
def build_problem_task(entity_id: str, name: str, *, for_minutes: int = 0) -> dict[str, Any]:
"""The task payload for an adopted problem sensor: manual schedule (no
calendar), triggered while the problem is on, auto-completed on recovery."""
calendar), triggered while the problem is on, auto-completed on recovery.
``for_minutes`` (#136) makes the problem state HOLD that long before the
task triggers — the opt-in flicker filter for sensors that report problems
for a few seconds at night. 0 keeps the trigger-on-first-flicker default.
"""
# A concise task title; the sensor's friendly name often already reads like
# "Printer problem", so keep it as-is rather than double-prefixing.
trigger_config: dict[str, Any] = {
"type": "state_change",
"entity_ids": [entity_id],
"trigger_to_state": "on",
"trigger_target_changes": 1,
"auto_complete_on_recovery": True,
}
if for_minutes > 0:
trigger_config["trigger_for_minutes"] = for_minutes
return {
"name": name,
"task_type": "inspection",
"schedule": {"kind": "manual"},
"trigger_config": {
"type": "state_change",
"entity_ids": [entity_id],
"trigger_to_state": "on",
"trigger_target_changes": 1,
"auto_complete_on_recovery": True,
},
"trigger_config": trigger_config,
}
@@ -31,6 +31,8 @@ from ..const import (
MAX_INTERVAL_DAYS,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_NFC_TAG_LENGTH,
MAX_READING_UNIT_LENGTH,
MAX_SCHEDULE_TIME_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
@@ -50,7 +52,7 @@ _TASK_STR_LIMITS: dict[str, int] = {
"notes": MAX_TEXT_LENGTH,
"documentation_url": MAX_URL_LENGTH,
"custom_icon": MAX_ICON_LENGTH,
"nfc_tag_id": 256,
"nfc_tag_id": MAX_NFC_TAG_LENGTH,
"responsible_user_id": MAX_META_LENGTH,
"entity_slug": MAX_ENTITY_SLUG_LENGTH,
"created_at": MAX_DATE_LENGTH,
@@ -61,7 +63,7 @@ _TASK_STR_LIMITS: dict[str, int] = {
"archived_reason": MAX_META_LENGTH,
"schedule_time": MAX_SCHEDULE_TIME_LENGTH,
"priority": MAX_TYPE_LENGTH,
"reading_unit": 32,
"reading_unit": MAX_READING_UNIT_LENGTH,
}
_OBJECT_STR_LIMITS: dict[str, int] = {
@@ -23,7 +23,7 @@ from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH, TaskPriority
from ..const import CONF_SAVED_FILTER_VIEWS, DEFAULT_TASK_PRIORITY, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH, TaskPriority
from .global_options import get_global_options
# Closed value sets mirrored from the panel's filter controls. Anything outside
@@ -98,7 +98,7 @@ def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bo
# Tasks without an explicit priority are "normal" everywhere (the model
# default) — the filter must see them the same way.
priority = filters.get("priority")
if priority and (task.get("priority") or "normal") != priority:
if priority and (task.get("priority") or DEFAULT_TASK_PRIORITY) != priority:
return False
return True
@@ -21,6 +21,8 @@ from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
from ..const import UNAVAILABLE_STATES
if TYPE_CHECKING:
from homeassistant.core import State
@@ -72,7 +74,7 @@ def threshold_exceeds(
def _numeric_entity_value(get_state: StateGetter, entity_id: str, attribute: str | None) -> float | None:
"""Read a numeric value from an entity state/attribute (None when unusable)."""
state = get_state(entity_id)
if state is None or state.state in ("unavailable", "unknown"):
if state is None or state.state in UNAVAILABLE_STATES:
return None
try:
raw = state.attributes.get(attribute) if attribute else state.state