142 files
This commit is contained in:
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:
|
||||
|
||||
Reference in new issue
Block a user