Files
HomeAssistantVS/custom_components/maintenance_supporter/websocket/battery_fleet.py
T

172 lines
7.1 KiB
Python

"""WebSocket commands for the Battery Fleet.
``battery_fleet/overview`` (read) returns the live aggregated view — low now,
grouped shopping needs, forecast — for the fleet task's detail. ``setup``
(admin write) creates the fleet object + type-parts + the single task.
"""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import (
async_compute_overview,
async_level_history,
fleet_excluded_entities,
has_batteries,
has_battery_notes,
read_batteries,
)
from ..helpers.battery_fleet_setup import (
async_mark_replaced,
async_setup_battery_fleet,
find_fleet_entry,
fleet_task_trigger_ok,
set_battery_excluded,
)
from ..helpers.permissions import require_write
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
@websocket_api.async_response
async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Return the live fleet overview (low now, needs grouped, forecast).
Goes through the ASYNC path so the ~dates use the discharge-trend
regression where the recorder data supports it (type table otherwise).
"""
ov = await async_compute_overview(hass)
fleet = find_fleet_entry(hass)
connection.send_result(
msg["id"],
{
"available": has_batteries(hass),
"has_battery_notes": has_battery_notes(hass),
"configured": fleet is not None,
# False when the fleet task was deleted or its trigger was wiped
# (issue #106) — the detail section offers a one-click repair,
# which re-runs the idempotent setup.
"task_ok": fleet is not None and fleet_task_trigger_ok(fleet),
"entry_id": fleet.entry_id if fleet else None,
"total": ov.total,
"low": ov.low,
"soon": ov.soon,
# The full roster, each row tagged low/soon/ok. The detail section
# lists it behind a disclosure so a device can be excluded before
# it ever goes low — previously the exclude control existed only on
# low rows, so a self-recharging vacuum could be dismissed only
# while it was already nagging (discussion #113).
"all": ov.all,
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"types": ov.types,
# Manually excluded batteries (issue #107) — names enriched where
# the entity still exists, so the restore list stays readable.
"excluded": [
{
"entity_id": eid,
"device_name": (
(st := hass.states.get(eid)) is not None
and (st.attributes.get("device_name") or st.attributes.get("friendly_name"))
)
or eid,
}
for eid in sorted(fleet_excluded_entities(hass))
],
},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/status"})
@callback
def ws_battery_fleet_status(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""The two booleans behind the panel's one-click-setup button — cheap.
The panel used to ask the FULL overview on every load just to decide
whether to render the button, which runs the trend machinery (one
recorder regression per healthy battery on a cold cache after every HA
restart). This answers ``available`` (any trackable battery) and
``configured`` (a fleet object exists) without reading the fleet at all.
"""
fleet = find_fleet_entry(hass)
connection.send_result(
msg["id"],
{"available": has_batteries(hass), "configured": fleet is not None},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview_history"})
@websocket_api.async_response
async def ws_battery_fleet_history(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Per-battery 30 d level history for the roster sparklines (read tier,
like overview — it renders the same data a user already sees as numbers).
Fetched lazily by the panel when the roster is expanded; the recorder
work behind it is 6 h-cached per entity (misses included), so repeated
opens are cheap. ``{series: {entity_id: {points: [[epoch_s, level]…],
threshold}}}`` — the threshold is the same one the trend forecast asks
about, so the frontend can draw the projection down to it.
"""
series = await async_level_history(hass, read_batteries(hass))
connection.send_result(msg["id"], {"series": series})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/setup",
# The caller's UI language (same contract as the template WS) —
# the created object/task/part names are localized through _T.
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_setup(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Create (or reconcile) the Battery Fleet object + type-parts + task."""
if not has_batteries(hass):
connection.send_error(msg["id"], "not_available", "No battery devices found")
return
result = await async_setup_battery_fleet(hass, language=msg.get("language"))
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/set_excluded",
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
vol.Required("excluded"): bool,
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_set_excluded(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Manually exclude a battery from the fleet (or take it back in) — #107."""
if not set_battery_excluded(hass, msg["entity_id"], msg["excluded"]):
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",
# battery_plus entity_ids to mark; omit to mark ALL currently low.
vol.Optional("entity_ids"): [vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH))],
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_mark_replaced(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Mark batteries replaced (press their button + consume the type-parts)."""
result = await async_mark_replaced(hass, msg.get("entity_ids"))
connection.send_result(msg["id"], result)