197 files

This commit is contained in:
Home Assistant Version Control
2026-08-21 18:53:37 +00:00
parent 89d9f5e49b
commit 383e9ed04e
197 changed files with 4386 additions and 1837 deletions
@@ -13,9 +13,11 @@ from homeassistant.core import HomeAssistant, callback
from ..const import (
BATTERY_FLEET_EXCLUDED,
BATTERY_FLEET_OBJECT_FLAG,
BATTERY_FLEET_TASK_FLAG,
CONF_GROUPS,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_TASK_PRIORITY,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
@@ -149,7 +151,7 @@ def _build_task_summary(
# auto-created "buy" reminder, the owning part marker ({part_id}).
"consumes_parts": task_data.get("consumes_parts"),
"part_ref": task_data.get("part_ref"),
"priority": task_data.get("priority", "normal"),
"priority": task_data.get("priority") or DEFAULT_TASK_PRIORITY,
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
# `archived` is the convenience bool the frontend filters on; reason is
# manual | auto | object. All read from the persisted (static) task dict.
@@ -187,7 +189,7 @@ def _build_task_summary(
"trigger_config": trigger_config,
# Battery Fleet: marks the single aggregate task so the detail view
# renders the battery section instead of the generic trigger card.
"battery_fleet_task": task_data.get("battery_fleet_task", False),
"battery_fleet_task": task_data.get(BATTERY_FLEET_TASK_FLAG, False),
"trigger_entity_info": trigger_entity_info,
"trigger_entity_infos": trigger_entity_infos,
"checklist": task_data.get("checklist", []),
@@ -534,6 +536,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_battery_fleet_overview,
ws_battery_fleet_set_excluded,
ws_battery_fleet_set_included,
ws_battery_fleet_set_track_self_charging,
ws_battery_fleet_setup,
ws_battery_fleet_status,
)
@@ -689,6 +692,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
websocket_api.async_register_command(hass, ws_battery_fleet_set_included)
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
@@ -18,6 +18,7 @@ from ..helpers.battery_fleet import (
async_compute_overview,
async_level_history,
fleet_excluded_entities,
fleet_track_self_charging,
has_batteries,
has_battery_notes,
read_batteries,
@@ -28,6 +29,8 @@ from ..helpers.battery_fleet_setup import (
find_fleet_entry,
fleet_task_trigger_ok,
set_battery_excluded,
set_battery_included,
set_track_self_charging,
)
from ..helpers.permissions import require_write
@@ -65,6 +68,8 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"types": ov.types,
# #135 follow-up: the roster's track-self-charging toggle state.
"track_self_charging": fleet_track_self_charging(hass),
# Manually excluded batteries (issue #107) — names enriched where
# the entity still exists, so the restore list stays readable.
"excluded": [
@@ -167,14 +172,30 @@ async def ws_battery_fleet_set_included(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Manually ADD a battery the discovery heuristics miss (#135)."""
from ..helpers.battery_fleet_setup import set_battery_included
if not set_battery_included(hass, msg["entity_id"], msg["included"]):
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
return
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/set_track_self_charging",
vol.Required("enabled"): bool,
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_set_track_self_charging(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Fleet-wide opt-in: keep self-charging devices in the roster (#135)."""
if not set_track_self_charging(hass, msg["enabled"]):
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
return
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/mark_replaced",
@@ -49,6 +49,10 @@ _SELECTION_SCHEMA = vol.Schema(
# Responsible HA user for the created task (the adopt dialog offers one
# picker applied to every selection). Wins over a stashed value.
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
# #136: minutes the problem state must HOLD before the task triggers
# (one dialog field applied to every selection). 0/omitted = trigger
# on the first flicker, the pre-#136 behaviour.
vol.Optional("for_minutes"): vol.Any(vol.All(int, vol.Range(min=0, max=1440)), None),
}
)
@@ -114,7 +118,7 @@ async def ws_adopt_problem_sensors(
errors.append({"entity_id": entity_id, "reason": "target object not found"})
continue
task = build_problem_task(entity_id, sel["name"])
task = build_problem_task(entity_id, sel["name"], for_minutes=sel.get("for_minutes") or 0)
task_data = {
"id": uuid4().hex,
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
@@ -17,6 +17,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
@@ -33,6 +34,8 @@ from ..const import (
MAX_LABELS,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_NFC_TAG_LENGTH,
MAX_READING_UNIT_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
MAX_URL_LENGTH,
@@ -112,8 +115,9 @@ TASK_UPDATE_FIELD_MAP = {
}
@websocket_api.websocket_command(
{
# Hoisted as a module constant so the schema/field-map parity tripwire
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/create",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
@@ -146,9 +150,9 @@ TASK_UPDATE_FIELD_MAP = {
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
@@ -173,7 +177,9 @@ TASK_UPDATE_FIELD_MAP = {
vol.Optional("enabled", default=True): bool,
vol.Optional("dry_run", default=False): bool,
}
)
@websocket_api.websocket_command(_TASK_CREATE_SCHEMA)
@require_write
@websocket_api.async_response
async def ws_create_task(
@@ -377,8 +383,9 @@ async def ws_create_task(
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
# Hoisted as a module constant so the schema/field-map parity tripwire
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
_TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
@@ -410,9 +417,9 @@ async def ws_create_task(
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
@@ -430,7 +437,9 @@ async def ws_create_task(
vol.Optional("on_complete_action"): vol.Any(dict, None),
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
}
)
@websocket_api.websocket_command(_TASK_UPDATE_SCHEMA)
@require_write
@websocket_api.async_response
async def ws_update_task(
@@ -464,7 +473,7 @@ async def ws_update_task(
# null can only be a client (possibly stale/cached bundle) failing to
# round-trip a trigger it did not hydrate. Ignore the null; every other
# field in the message still applies.
if task.get("battery_fleet_task") and "trigger_config" in msg and msg["trigger_config"] is None:
if task.get(BATTERY_FLEET_TASK_FLAG) and "trigger_config" in msg and msg["trigger_config"] is None:
del msg["trigger_config"]
# Validate trigger_config if provided
@@ -14,6 +14,7 @@ from ..const import (
CONF_OBJECT,
CONF_OBJECT_NAME,
CONF_TASKS,
UNAVAILABLE_STATES,
TriggerType,
)
from . import (
@@ -158,7 +159,7 @@ def _validate_trigger_config(
state = hass.states.get(eid)
if state is None:
warnings.append(f"Entity {eid} does not exist (yet)")
elif state.state in ("unavailable", "unknown"):
elif state.state in UNAVAILABLE_STATES:
warnings.append(f"Entity {eid} is currently '{state.state}'")
# Ensure entity_id is set for backwards compat
if not trigger_config.get("entity_id"):