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

217 lines
7.8 KiB
Python

"""WebSocket endpoints for vacation mode (v1.2.0)."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
CONF_OBJECT,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
DOMAIN,
MAX_ID_LENGTH,
MAX_INTERVAL_DAYS,
MAX_VACATION_EXEMPT_TASKS,
)
from ..helpers.aggregate import object_name
from ..helpers.dates import parse_iso_date
from ..helpers.pause import is_task_inert
from ..helpers.vacation import compute_preview, get_vacation_state
from . import ID_FIELD, _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
"""Serialise the current VacationState for the wire."""
return get_vacation_state(hass).as_wire_dict()
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/state"})
@websocket_api.async_response
async def ws_vacation_state(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the current vacation configuration + active flag."""
connection.send_result(msg["id"], _state_payload(hass))
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/vacation/update",
vol.Optional("enabled"): bool,
vol.Optional("start"): vol.Any(vol.All(str, vol.Length(max=10)), None),
vol.Optional("end"): vol.Any(vol.All(str, vol.Length(max=10)), None),
vol.Optional("buffer_days"): vol.All(int, vol.Range(min=0, max=14)),
vol.Optional("exempt_task_ids"): vol.All(
[ID_FIELD],
vol.Length(max=MAX_VACATION_EXEMPT_TASKS),
),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_vacation_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Patch vacation config on the global entry. Partial updates allowed."""
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
if "enabled" in msg:
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
# A vacation is a stretch of days, not a lifetime: a date more than
# MAX_INTERVAL_DAYS (10 years) out is refused. 9999-12-31 plus the
# return buffer overflowed the calendar inside every object's refresh
# and took the objects down for the whole "vacation" (bug audit
# 2026-09-27; helpers.vacation now also clamps what is already stored).
latest = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
for field, key in (("start", CONF_VACATION_START), ("end", CONF_VACATION_END)):
if field not in msg:
continue
if msg[field] is not None:
parsed = _parse_iso_date(connection, msg["id"], msg[field], field=field)
if parsed is None:
return
if parsed > latest:
connection.send_error(
msg["id"], "invalid_range", f"{field} must be within {MAX_INTERVAL_DAYS} days from today"
)
return
options[key] = msg[field]
# End-vs-start sanity (only when both are present after the patch; a
# stored value that does not parse is skipped — the patch above already
# refused a malformed incoming one).
sd = parse_iso_date(options.get(CONF_VACATION_START))
ed = parse_iso_date(options.get(CONF_VACATION_END))
if sd is not None and ed is not None and ed < sd:
connection.send_error(msg["id"], "invalid_range", "end must be on or after start")
return
if "buffer_days" in msg:
options[CONF_VACATION_BUFFER_DAYS] = int(msg["buffer_days"])
if "exempt_task_ids" in msg:
# Sanitise: strip + dedupe + cap.
seen: set[str] = set()
cleaned: list[str] = []
for raw in msg["exempt_task_ids"]:
if not isinstance(raw, str):
continue
v = raw.strip()
if not v or len(v) > MAX_ID_LENGTH or v in seen:
continue
seen.add(v)
cleaned.append(v)
if len(cleaned) >= MAX_VACATION_EXEMPT_TASKS:
break
options[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], _state_payload(hass))
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/preview"})
@websocket_api.async_response
async def ws_vacation_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the projected impact of the currently-configured vacation.
Even works when the toggle is off — useful for the "Preview impact"
button before the user enables it. A vacation without start/end returns
an empty list.
"""
state = get_vacation_state(hass)
# If the user is previewing without enabling, still compute against the
# currently-stored dates. Caller is responsible for passing dates via
# /update first if they want a live preview during date entry.
if state.start is None or state.end is None:
connection.send_result(msg["id"], {"rows": [], "window_end": None})
return
# Hand the preview each task's MERGED dict (static + Store) so it builds
# the real MaintenanceTask — a hand-picked flat subset dropped
# due_override, the seasonal window, the planned anchor and one-time due
# dates (bug audit 2026-09-26, DRY BR-A1). Inert tasks (archived,
# disabled, paused object) fire nothing during the absence either, so
# they are no preview rows.
tasks: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
obj_name = object_name(entry)
obj = entry.data.get(CONF_OBJECT, {})
for task_id, task_data in _get_merged_tasks(entry).items():
if is_task_inert(task_data, obj):
continue
tasks.append(
{
**task_data,
"task_id": task_id,
"entry_id": entry.entry_id,
"object_name": obj_name,
"task_name": task_data.get("name", ""),
}
)
rows = compute_preview(state, tasks)
connection.send_result(
msg["id"],
{
"rows": rows,
"window_end": state.window_end.isoformat() if state.window_end else None,
},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/end_now"})
@websocket_api.require_admin
@websocket_api.async_response
async def ws_vacation_end_now(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Disable vacation mode immediately, preserve the date config for reuse."""
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
options[CONF_VACATION_ENABLED] = False
# Optionally clamp end-date to today so the historical record reflects when
# the user actually returned. Use HA's configured timezone — the user's
# "today" should match what their dashboard shows, not the server's UTC.
today = dt_util.now().date()
sd = parse_iso_date(options.get(CONF_VACATION_START))
if sd is not None and sd <= today:
options[CONF_VACATION_END] = today.isoformat()
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], _state_payload(hass))
__all__ = [
"ws_vacation_end_now",
"ws_vacation_preview",
"ws_vacation_state",
"ws_vacation_update",
]