224 files
This commit is contained in:
@@ -429,6 +429,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
from .battery_fleet import (
|
||||
ws_battery_fleet_mark_replaced,
|
||||
ws_battery_fleet_overview,
|
||||
ws_battery_fleet_set_excluded,
|
||||
ws_battery_fleet_setup,
|
||||
)
|
||||
from .dashboard import (
|
||||
@@ -467,6 +468,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_get_templates,
|
||||
ws_import_csv,
|
||||
ws_import_json,
|
||||
ws_version,
|
||||
)
|
||||
from .objects import (
|
||||
ws_archive_object,
|
||||
@@ -556,6 +558,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_restock_part)
|
||||
websocket_api.async_register_command(hass, ws_update_history_entry)
|
||||
websocket_api.async_register_command(hass, ws_get_templates)
|
||||
websocket_api.async_register_command(hass, ws_version)
|
||||
websocket_api.async_register_command(hass, ws_export_data)
|
||||
websocket_api.async_register_command(hass, ws_get_budget_status)
|
||||
websocket_api.async_register_command(hass, ws_schedule_preview)
|
||||
@@ -568,6 +571,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_overview)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
|
||||
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_discover_integration_setups)
|
||||
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
|
||||
websocket_api.async_register_command(hass, ws_list_saved_views)
|
||||
|
||||
@@ -14,12 +14,13 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
|
||||
from ..helpers.battery_fleet import compute_overview, has_batteries, has_battery_notes
|
||||
from ..helpers.battery_fleet import compute_overview, fleet_excluded_entities, has_batteries, has_battery_notes
|
||||
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
|
||||
|
||||
@@ -47,6 +48,19 @@ 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,
|
||||
# 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))
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -70,6 +84,25 @@ async def ws_battery_fleet_setup(hass: HomeAssistant, connection: websocket_api.
|
||||
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",
|
||||
|
||||
@@ -88,6 +88,22 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/version"})
|
||||
@websocket_api.async_response
|
||||
async def ws_version(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
|
||||
"""The installed integration version (manifest).
|
||||
|
||||
Roadmap guard 2 — stale-bundle handshake: the panel compares this against
|
||||
the version esbuild stamped into its bundle and offers a reload when a
|
||||
cached old frontend is talking to a newer backend (HA's service worker
|
||||
updates stale-while-revalidate, so this happens routinely after updates).
|
||||
"""
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
connection.send_result(msg["id"], {"version": integration.version})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/templates",
|
||||
@@ -108,7 +124,7 @@ async def ws_get_templates(
|
||||
curation): the pickers hide disabled ones client-side, while the Settings
|
||||
section needs the full list to render the toggles.
|
||||
"""
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..helpers.i18n import normalize_language, normalize_language_code
|
||||
from ..templates import (
|
||||
TEMPLATE_CATEGORIES,
|
||||
TEMPLATES,
|
||||
@@ -117,7 +133,7 @@ async def ws_get_templates(
|
||||
)
|
||||
|
||||
disabled = get_disabled_template_ids(hass)
|
||||
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
|
||||
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
|
||||
|
||||
result = {
|
||||
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
|
||||
@@ -554,6 +570,11 @@ async def ws_import_json(
|
||||
wd = task_data.get("warning_days")
|
||||
if not isinstance(wd, int) or wd < 0 or wd > 365:
|
||||
task_data["warning_days"] = get_default_warning_days(hass)
|
||||
# A rotation task must carry its effective assignee (imports from
|
||||
# pre-seeding exports may lack one) — same rule as create/update.
|
||||
from ..helpers.sanitize import seed_rotation_assignee
|
||||
|
||||
seed_rotation_assignee(task_data)
|
||||
# Sanitize checklist: only keep string items within length budget,
|
||||
# cap total items. Drops malformed entries silently rather than
|
||||
# rejecting the whole import — same forgiving model as the other
|
||||
|
||||
@@ -523,7 +523,7 @@ async def ws_create_from_template(
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..helpers.i18n import normalize_language, normalize_language_code
|
||||
from ..templates import get_template_by_id, localize_template_text
|
||||
|
||||
template = get_template_by_id(msg["template_id"])
|
||||
@@ -531,7 +531,7 @@ async def ws_create_from_template(
|
||||
connection.send_error(msg["id"], "not_found", "Template not found")
|
||||
return
|
||||
|
||||
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
|
||||
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
|
||||
default_name = localize_template_text(template.name, lang) or template.name
|
||||
name = (msg.get("name") or default_name).strip() or default_name
|
||||
# Auto-number on collision: applying the same template twice (or owning
|
||||
|
||||
@@ -68,6 +68,47 @@ from .tasks_validation import (
|
||||
_validate_trigger_config,
|
||||
)
|
||||
|
||||
# ws_update_task: wire key -> storage key. Almost all are identity; the one
|
||||
# rename is deliberate and load-bearing: the WS message envelope reserves
|
||||
# "type" for command routing ({"type": "maintenance_supporter/task/update"}),
|
||||
# so a task's own type must travel as "task_type" on the wire and is stored
|
||||
# as "type". Do NOT "simplify" this to "type": "type" — it would collide with
|
||||
# the routing key. The panel↔config-flow parity test (test_parity_task_fields)
|
||||
# encodes the same task_type->type alias. Module-level so the CONTRACT-FIXTURE
|
||||
# tripwire (tests/test_task_contract_fixture.py) can enumerate it: a field
|
||||
# added here without extending the round-trip fixture fails that test.
|
||||
TASK_UPDATE_FIELD_MAP = {
|
||||
"name": "name",
|
||||
"task_type": "type",
|
||||
"enabled": "enabled",
|
||||
"schedule_type": "schedule_type",
|
||||
"interval_days": "interval_days",
|
||||
"interval_unit": "interval_unit",
|
||||
"due_date": "due_date",
|
||||
"interval_anchor": "interval_anchor",
|
||||
"warning_days": "warning_days",
|
||||
"earliest_completion_days": "earliest_completion_days",
|
||||
"last_performed": "last_performed",
|
||||
"trigger_config": "trigger_config",
|
||||
"notes": "notes",
|
||||
"documentation_url": "documentation_url",
|
||||
"responsible_user_id": "responsible_user_id",
|
||||
"assignee_pool": "assignee_pool",
|
||||
"rotation_strategy": "rotation_strategy",
|
||||
"entity_slug": "entity_slug",
|
||||
"custom_icon": "custom_icon",
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"reading_unit": "reading_unit",
|
||||
"consumes_parts": "consumes_parts",
|
||||
"priority": "priority",
|
||||
"checklist": "checklist",
|
||||
"labels": "labels",
|
||||
"schedule_time": "schedule_time",
|
||||
# v1.3.0
|
||||
"on_complete_action": "on_complete_action",
|
||||
"quick_complete_defaults": "quick_complete_defaults",
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
@@ -242,6 +283,9 @@ async def ws_create_task(
|
||||
task_data["assignee_pool"] = sanitize_assignee_pool(msg["assignee_pool"])
|
||||
if msg.get("rotation_strategy"):
|
||||
task_data["rotation_strategy"] = msg["rotation_strategy"]
|
||||
from ..helpers.sanitize import seed_rotation_assignee
|
||||
|
||||
seed_rotation_assignee(task_data)
|
||||
if msg.get("entity_slug") is not None:
|
||||
slug = msg["entity_slug"]
|
||||
if not re.fullmatch(r"[a-z0-9_]+", slug):
|
||||
@@ -452,45 +496,7 @@ async def ws_update_task(
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
# Update provided fields. Wire key -> storage key. Almost all are identity;
|
||||
# the one rename is deliberate and load-bearing: the WS message envelope
|
||||
# reserves "type" for command routing ({"type": "maintenance_supporter/
|
||||
# task/update"}), so a task's own type must travel as "task_type" on the
|
||||
# wire and is stored as "type". Do NOT "simplify" this to "type": "type"
|
||||
# — it would collide with the routing key. The panel↔config-flow parity
|
||||
# test (test_parity_task_fields) encodes the same task_type->type alias.
|
||||
field_map = {
|
||||
"name": "name",
|
||||
"task_type": "type",
|
||||
"enabled": "enabled",
|
||||
"schedule_type": "schedule_type",
|
||||
"interval_days": "interval_days",
|
||||
"interval_unit": "interval_unit",
|
||||
"due_date": "due_date",
|
||||
"interval_anchor": "interval_anchor",
|
||||
"warning_days": "warning_days",
|
||||
"earliest_completion_days": "earliest_completion_days",
|
||||
"last_performed": "last_performed",
|
||||
"trigger_config": "trigger_config",
|
||||
"notes": "notes",
|
||||
"documentation_url": "documentation_url",
|
||||
"responsible_user_id": "responsible_user_id",
|
||||
"assignee_pool": "assignee_pool",
|
||||
"rotation_strategy": "rotation_strategy",
|
||||
"entity_slug": "entity_slug",
|
||||
"custom_icon": "custom_icon",
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"reading_unit": "reading_unit",
|
||||
"consumes_parts": "consumes_parts",
|
||||
"priority": "priority",
|
||||
"checklist": "checklist",
|
||||
"labels": "labels",
|
||||
"schedule_time": "schedule_time",
|
||||
# v1.3.0
|
||||
"on_complete_action": "on_complete_action",
|
||||
"quick_complete_defaults": "quick_complete_defaults",
|
||||
}
|
||||
for msg_key, data_key in field_map.items():
|
||||
for msg_key, data_key in TASK_UPDATE_FIELD_MAP.items():
|
||||
if msg_key in msg:
|
||||
task[data_key] = msg[msg_key]
|
||||
|
||||
@@ -534,6 +540,7 @@ async def ws_update_task(
|
||||
cap_quick_complete_defaults_field,
|
||||
sanitize_assignee_pool,
|
||||
sanitize_labels,
|
||||
seed_rotation_assignee,
|
||||
)
|
||||
|
||||
cap_action_field(task)
|
||||
@@ -542,6 +549,7 @@ async def ws_update_task(
|
||||
task["labels"] = sanitize_labels(task["labels"])
|
||||
if "assignee_pool" in task:
|
||||
task["assignee_pool"] = sanitize_assignee_pool(task["assignee_pool"])
|
||||
seed_rotation_assignee(task)
|
||||
|
||||
# Clear stale trigger runtime in Store only when trigger fundamentally changes
|
||||
if "trigger_config" in msg:
|
||||
|
||||
Reference in New Issue
Block a user