142 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-20 17:17:53 +00:00
1 parent c987ad41b6
commit c73af9e207
142 files changed
+906 -17859

No files matched your search

@@ -337,6 +337,20 @@ def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
return set()
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
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()
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
"""Whether a device recharges itself — its battery is never REPLACED.
@@ -411,6 +425,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
excluded = fleet_excluded_entities(hass)
included = fleet_included_entities(hass)
out: list[Battery] = []
covered_sources: set[str] = set()
@@ -517,12 +532,16 @@ def read_batteries(hass: HomeAssistant) -> list[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
# #135: a manual include bypasses the discovery heuristic (and the
# self-charging filter below) — the user has stated this IS a
# battery. Coverage dedupe and the exclusion list still apply.
if eid not in included:
if domain == "sensor":
if not _is_native_battery_sensor(state):
continue
elif state.attributes.get("device_class") != "battery":
continue
if "battery_type" in state.attributes: # Battery Notes battery_plus — handled above
continue
if eid in covered_sources or eid in excluded:
@@ -531,7 +550,7 @@ 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 _is_self_charging(hass, dev_id): # #107: vacuums/mowers/phones
if eid not in included and _is_self_charging(hass, dev_id): # #107
continue
key = dev_id or eid
rec = native.setdefault(
@@ -855,6 +874,7 @@ __all__ = [
"compute_overview",
"discover_battery_types",
"fleet_excluded_entities",
"fleet_included_entities",
"has_batteries",
"has_battery_notes",
"is_rechargeable_type",
@@ -18,7 +18,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from ..const import BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_OBJECT_FLAG, CONF_OBJECT, CONF_PARTS, CONF_TASKS, DOMAIN
from ..const import (
BATTERY_FLEET_EXCLUDED,
BATTERY_FLEET_INCLUDED,
BATTERY_FLEET_OBJECT_FLAG,
CONF_OBJECT,
CONF_PARTS,
CONF_TASKS,
DOMAIN,
)
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, read_batteries
# The global aggregate sensor the fleet task triggers on (fixed entity_id).
@@ -349,7 +357,14 @@ def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) ->
obj = dict(new_data.get(CONF_OBJECT, {}))
current = set(obj.get(BATTERY_FLEET_EXCLUDED) or [])
if excluded:
current.add(entity_id)
# 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)
@@ -358,6 +373,34 @@ def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) ->
return True
def set_battery_included(hass: HomeAssistant, entity_id: str, included: bool) -> bool:
"""Persist a manual ADD of one battery to the fleet (#135).
The include bypasses the discovery heuristic and the self-charging filter
in the aggregation — for batteries the heuristics miss. Adding also lifts
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 find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
@@ -56,6 +56,10 @@ _COLUMNS = [
"custom_icon",
"nfc_tag_id",
"responsible_user_id",
# #134 audit: priority routes notifications/filters and labels drive views
# — a CSV migration losing them silently was the object_notes class again.
"priority",
"labels",
"trigger_type",
"status",
"times_performed",
@@ -126,6 +130,9 @@ 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",
# 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", ""),
"status": ct.get("_status", "ok"),
"times_performed": ct.get("_times_performed", 0),
@@ -308,6 +315,18 @@ def import_objects_csv(
if resp_user:
task_data["responsible_user_id"] = resp_user
# #134 audit: priority (closed set) + labels ("\n" cell like checklist).
priority = (row.get("priority") or "").strip().lower()
if priority in ("low", "normal", "high"):
task_data["priority"] = priority
labels_raw = row.get("labels") or ""
if labels_raw:
from ..const import MAX_LABEL_LENGTH, MAX_LABELS
labels = [line.strip()[:MAX_LABEL_LENGTH] for line in labels_raw.splitlines() if line.strip()][:MAX_LABELS]
if labels:
task_data["labels"] = labels
# Checklist round-trips via a single cell with "\n" between items.
# Apply the same hard caps as the WebSocket schema so a malicious or
# accidental CSV can't bloat the entry.
@@ -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
from ..const import CONF_SAVED_FILTER_VIEWS, 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
@@ -32,6 +32,8 @@ from .global_options import get_global_options
VALID_STATUSES = frozenset({"", "ok", "due_soon", "overdue", "triggered", "paused", "archived"})
VALID_SORT_MODES = frozenset({"due_date", "object", "type", "task_name", "area", "assigned_user", "group"})
VALID_GROUP_BY = frozenset({"none", "area", "group", "user"})
# Priority filter (#134): "" = no filter, else one of the TaskPriority values.
VALID_PRIORITIES = frozenset({"", *(p.value for p in TaskPriority)})
def _clean_filters(raw: Any) -> dict[str, Any]:
@@ -61,10 +63,17 @@ def _clean_filters(raw: Any) -> dict[str, Any]:
if isinstance(label, str) and len(label) > MAX_LABEL_LENGTH:
label = None
# #134: a priority filter ("only high-priority tasks") — closed set, "" =
# no filter. Together with the notification view-scope this gives
# priority-routed notifications without a dedicated setting.
priority = src.get("priority", "")
priority = priority if isinstance(priority, str) and priority in VALID_PRIORITIES else ""
return {
"status": status,
"user_id": user_id,
"label": label,
"priority": priority,
"archived": bool(src.get("archived")),
"sort_mode": sort_mode,
"group_by": group_by,
@@ -72,7 +81,7 @@ def _clean_filters(raw: Any) -> dict[str, Any]:
def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bool:
"""Does a task match a view's TASK-SELECTING filters (label + user)?
"""Does a task match a view's TASK-SELECTING filters (label/user/priority)?
Used by notification routing ("only notify about view X"). Deliberately
ignores the DISPLAY dimensions: ``status`` (the per-status notify toggles
@@ -86,6 +95,11 @@ def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bo
user_id = filters.get("user_id")
if user_id and user_id != "current_user" and task.get("responsible_user_id") != user_id:
return False
# 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:
return False
return True
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
StateGetter = Callable[[str], "State | None"]
@dataclass(slots=True)
@dataclass(slots=True) # pragma: no mutate (slots is memory, not behaviour)
class FallbackResult:
"""What a fallback evaluation learned.
@@ -130,7 +130,7 @@ def evaluate_counter(
"""Counter: value (or delta from a per-entity baseline) reaches a target."""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
target = trigger_config.get("trigger_target_value", 0)
target = trigger_config.get("trigger_target_value", 0) # pragma: no mutate (WS-required; default only guards hand-edited data)
delta_mode = trigger_config.get("trigger_delta_mode", False)
trigger_state = trigger_config.get("_trigger_state", {})
@@ -214,7 +214,7 @@ def evaluate_runtime(
from datetime import UTC
on_dt = on_dt.replace(tzinfo=UTC)
total += max(0.0, (dt_util.utcnow() - on_dt).total_seconds())
total += max(0.0, (dt_util.utcnow() - on_dt).total_seconds()) # pragma: no mutate (a 1s floor shift vanishes in the 2-decimal rounding)
hours = total / 3600.0
best_hours = hours if best_hours is None else max(best_hours, hours)
if target_hours: