"""WebSocket handlers for adaptive scheduling and analysis.""" from __future__ import annotations from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from ..const import ( CONF_TASKS, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_META_LENGTH, ) from ..helpers.permissions import require_write from . import _get_merged_tasks, _get_runtime_data, _load_object_entry @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/task/analyze_interval", 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)), } ) @websocket_api.async_response async def ws_analyze_interval( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return full interval analysis for a task (on-demand).""" from ..helpers.interval_analyzer import IntervalAnalyzer entry = _load_object_entry(hass, connection, msg) if entry is None: return tasks_data = _get_merged_tasks(entry) task_id = msg["task_id"] if task_id not in tasks_data: connection.send_error(msg["id"], "not_found", "Task not found") return task_data = tasks_data[task_id] adaptive_config = dict(task_data.get("adaptive_config", {})) # Inject hemisphere and current month for seasonal awareness from homeassistant.util import dt as dt_util adaptive_config["hemisphere"] = "south" if (hass.config.latitude or 0) < 0 else "north" adaptive_config["_current_month"] = dt_util.now().month analyzer = IntervalAnalyzer() analysis = analyzer.analyze(task_data, adaptive_config) connection.send_result( msg["id"], { "current_interval": analysis.current_interval, "average_actual_interval": analysis.average_actual_interval, "interval_std_dev": analysis.interval_std_dev, "ewa_prediction": analysis.ewa_prediction, "weibull_prediction": analysis.weibull_prediction, "weibull_beta": analysis.weibull_beta, "weibull_eta": analysis.weibull_eta, "recommended_interval": analysis.recommended_interval, "confidence": analysis.confidence, "feedback_count": analysis.feedback_count, "data_points": analysis.data_points, "recommendation_reason": analysis.recommendation_reason, "seasonal_factor": analysis.seasonal_factor, "seasonal_factors": analysis.seasonal_factors, "seasonal_reason": analysis.seasonal_adjustment_reason, "weibull_r_squared": analysis.weibull_r_squared, "confidence_interval_low": analysis.confidence_interval_low, "confidence_interval_high": analysis.confidence_interval_high, }, ) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/task/apply_suggestion", 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)), vol.Required("interval"): vol.All(int, vol.Range(min=1, max=3650)), } ) @require_write @websocket_api.async_response async def ws_apply_suggestion( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Apply a suggested interval to a task.""" rd = _get_runtime_data(hass, msg["entry_id"]) if rd is None or rd.coordinator is None: connection.send_error(msg["id"], "not_found", "Coordinator not found") return # Reject an unknown task_id instead of silently no-opping and returning # success (the apply call is a no-op for a missing task). if msg["task_id"] not in rd.coordinator.entry.data.get(CONF_TASKS, {}): connection.send_error(msg["id"], "not_found", "Task not found") return await rd.coordinator.async_apply_suggested_interval( task_id=msg["task_id"], interval=msg["interval"], ) connection.send_result(msg["id"], {"success": True}) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/task/seasonal_overrides", 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)), vol.Required("overrides"): dict, } ) @require_write @websocket_api.async_response async def ws_seasonal_overrides( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Set manual seasonal overrides for a task. Overrides is a dict of {month_num: factor}, e.g. {7: 0.5, 1: 2.0}. Keys must be 1-12, values must be 0.1-5.0. Pass empty dict {} to clear all overrides. """ entry = _load_object_entry(hass, connection, msg) if entry is None: return task_id = msg["task_id"] tasks_data = _get_merged_tasks(entry) if task_id not in tasks_data: connection.send_error(msg["id"], "not_found", "Task not found") return # Validate overrides overrides = msg["overrides"] validated: dict[int, float] = {} for key, value in overrides.items(): try: month = int(key) factor = float(value) except (ValueError, TypeError): connection.send_error(msg["id"], "invalid_input", f"Invalid override: key={key}, value={value}") return if month < 1 or month > 12: connection.send_error(msg["id"], "invalid_input", f"Month must be 1-12, got {month}") return if factor < 0.1 or factor > 5.0: connection.send_error(msg["id"], "invalid_input", f"Factor must be 0.1-5.0, got {factor}") return validated[month] = round(factor, 2) # Persist overrides in adaptive_config adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {})) if validated: adaptive_config["seasonal_overrides"] = validated else: adaptive_config.pop("seasonal_overrides", None) rd = _get_runtime_data(hass, entry.entry_id) store = getattr(rd, "store", None) if rd else None if store is not None: store.set_adaptive_config(task_id, adaptive_config) store.async_delay_save() else: # Legacy: write to ConfigEntry.data static_tasks = dict(entry.data.get(CONF_TASKS, {})) task = dict(static_tasks[task_id]) task["adaptive_config"] = adaptive_config static_tasks[task_id] = task new_data = dict(entry.data) new_data[CONF_TASKS] = static_tasks hass.config_entries.async_update_entry(entry, data=new_data) # Refresh coordinator if rd and rd.coordinator: await rd.coordinator.async_request_refresh() connection.send_result(msg["id"], {"success": True, "overrides": validated}) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/task/set_environmental_entity", 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)), vol.Optional("environmental_entity"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)), None), vol.Optional("environmental_attribute"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None), } ) @require_write @websocket_api.async_response async def ws_set_environmental_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Set or clear the environmental entity for sensor-driven predictions. When set, the environmental sensor (e.g. outdoor temperature) is correlated with maintenance intervals to produce an adjustment factor. Pass environmental_entity=null to clear the binding. """ entry = _load_object_entry(hass, connection, msg) if entry is None: return task_id = msg["task_id"] tasks_data = _get_merged_tasks(entry) if task_id not in tasks_data: connection.send_error(msg["id"], "not_found", "Task not found") return adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {})) env_entity = msg.get("environmental_entity") env_attribute = msg.get("environmental_attribute") if env_entity: adaptive_config["environmental_entity"] = env_entity if env_attribute: adaptive_config["environmental_attribute"] = env_attribute else: adaptive_config.pop("environmental_attribute", None) else: # Clear environmental binding adaptive_config.pop("environmental_entity", None) adaptive_config.pop("environmental_attribute", None) rd = _get_runtime_data(hass, entry.entry_id) store = getattr(rd, "store", None) if rd else None if store is not None: store.set_adaptive_config(task_id, adaptive_config) store.async_delay_save() else: # Legacy: write to ConfigEntry.data static_tasks = dict(entry.data.get(CONF_TASKS, {})) task = dict(static_tasks[task_id]) task["adaptive_config"] = adaptive_config static_tasks[task_id] = task new_data = dict(entry.data) new_data[CONF_TASKS] = static_tasks hass.config_entries.async_update_entry(entry, data=new_data) # Refresh coordinator if rd and rd.coordinator: await rd.coordinator.async_request_refresh() connection.send_result( msg["id"], { "success": True, "environmental_entity": env_entity, "environmental_attribute": env_attribute, }, )