|
4 | 4 |
|
5 | 5 | import datetime |
6 | 6 | import logging |
| 7 | +import re |
7 | 8 | from dataclasses import dataclass |
8 | 9 | from datetime import timedelta |
9 | 10 | from typing import TYPE_CHECKING, Any |
10 | 11 |
|
| 12 | +import voluptuous as vol |
11 | 13 | from homeassistant.components.switch import ( |
12 | 14 | SwitchEntity, |
13 | 15 | SwitchEntityDescription, |
14 | 16 | ) |
15 | | -from homeassistant.const import EntityCategory |
| 17 | +from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN |
| 18 | +from homeassistant.const import ATTR_ENTITY_ID, EntityCategory |
| 19 | +from homeassistant.exceptions import ServiceValidationError |
| 20 | +from homeassistant.helpers import config_validation as cv |
16 | 21 |
|
17 | 22 | from . import BHyveCoordinatorEntity |
18 | 23 | from .const import ( |
|
26 | 31 |
|
27 | 32 | if TYPE_CHECKING: |
28 | 33 | from homeassistant.config_entries import ConfigEntry |
29 | | - from homeassistant.core import HomeAssistant |
| 34 | + from homeassistant.core import HomeAssistant, ServiceCall |
30 | 35 | from homeassistant.helpers.entity_platform import AddEntitiesCallback |
31 | 36 |
|
32 | 37 | from .coordinator import BHyveDataUpdateCoordinator |
|
78 | 83 | "start_times", |
79 | 84 | } |
80 | 85 |
|
| 86 | +# Service constants |
| 87 | +SERVICE_UPDATE_PROGRAM = "update_program" |
| 88 | +ATTR_START_TIMES = "start_times" |
| 89 | +ATTR_FREQUENCY = "frequency" |
| 90 | + |
| 91 | +_TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") |
| 92 | + |
| 93 | + |
| 94 | +def _validate_time_string(value: Any) -> str: |
| 95 | + """Validate an HH:MM time string.""" |
| 96 | + if not isinstance(value, str) or not _TIME_RE.match(value): |
| 97 | + msg = f"Invalid time '{value}', expected HH:MM" |
| 98 | + raise vol.Invalid(msg) |
| 99 | + return value |
| 100 | + |
| 101 | + |
| 102 | +FREQUENCY_SCHEMA = vol.Schema( |
| 103 | + { |
| 104 | + vol.Required("type"): cv.string, |
| 105 | + vol.Optional("days"): [vol.All(int, vol.Range(min=0, max=6))], |
| 106 | + vol.Optional("interval"): vol.All(int, vol.Range(min=0)), |
| 107 | + vol.Optional("interval_hours"): vol.All(int, vol.Range(min=0)), |
| 108 | + }, |
| 109 | + extra=vol.ALLOW_EXTRA, |
| 110 | +) |
| 111 | + |
| 112 | +UPDATE_PROGRAM_SCHEMA = vol.Schema( |
| 113 | + { |
| 114 | + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, |
| 115 | + vol.Optional(ATTR_START_TIMES): vol.All( |
| 116 | + cv.ensure_list, [_validate_time_string] |
| 117 | + ), |
| 118 | + vol.Optional(ATTR_FREQUENCY): FREQUENCY_SCHEMA, |
| 119 | + } |
| 120 | +) |
| 121 | + |
81 | 122 |
|
82 | 123 | @dataclass(frozen=True, kw_only=True) |
83 | 124 | class BHyveSwitchEntityDescription(SwitchEntityDescription): |
@@ -170,6 +211,31 @@ async def async_setup_entry( |
170 | 211 |
|
171 | 212 | async_add_entities(switches) |
172 | 213 |
|
| 214 | + async def async_update_program_service(call: ServiceCall) -> None: |
| 215 | + """Handle the update_program service call.""" |
| 216 | + entity_ids = call.data.get(ATTR_ENTITY_ID) or [] |
| 217 | + params = {k: v for k, v in call.data.items() if k != ATTR_ENTITY_ID} |
| 218 | + |
| 219 | + component = hass.data.get(SWITCH_DOMAIN) |
| 220 | + if component is None: |
| 221 | + _LOGGER.warning("Switch component not available, cannot update program") |
| 222 | + return |
| 223 | + |
| 224 | + for entity_id in entity_ids: |
| 225 | + entity = component.get_entity(entity_id) |
| 226 | + if not isinstance(entity, BHyveProgramSwitch): |
| 227 | + msg = f"Entity {entity_id} is not a BHyve program switch" |
| 228 | + raise ServiceValidationError(msg) |
| 229 | + await entity.async_update_program_config(**params) |
| 230 | + |
| 231 | + if not hass.services.has_service(DOMAIN, SERVICE_UPDATE_PROGRAM): |
| 232 | + hass.services.async_register( |
| 233 | + DOMAIN, |
| 234 | + SERVICE_UPDATE_PROGRAM, |
| 235 | + async_update_program_service, |
| 236 | + schema=UPDATE_PROGRAM_SCHEMA, |
| 237 | + ) |
| 238 | + |
173 | 239 | # Listen for new programs created via WebSocket |
174 | 240 | async def async_handle_program_created(event: Any) -> None: |
175 | 241 | """Handle creation of new programs.""" |
@@ -260,6 +326,36 @@ async def _update_program(self, *, enabled: bool) -> None: |
260 | 326 | program["enabled"] = enabled |
261 | 327 | await self.coordinator.client.update_program(self._program_id, program) |
262 | 328 |
|
| 329 | + async def async_update_program_config( |
| 330 | + self, |
| 331 | + start_times: list[str] | None = None, |
| 332 | + frequency: dict | None = None, |
| 333 | + ) -> None: |
| 334 | + """Update start times and/or frequency on a non-smart program.""" |
| 335 | + if self.program_data.get("is_smart_program"): |
| 336 | + msg = "Cannot update configuration of a smart program" |
| 337 | + raise ServiceValidationError(msg) |
| 338 | + |
| 339 | + if start_times is None and frequency is None: |
| 340 | + msg = "At least one of start_times or frequency must be provided" |
| 341 | + raise ServiceValidationError(msg) |
| 342 | + |
| 343 | + program = BHyveTimerProgram( |
| 344 | + {k: v for k, v in self.program_data.items() if k in PROGRAM_UPDATE_KEYS} |
| 345 | + ) |
| 346 | + changed: list[str] = [] |
| 347 | + if start_times is not None: |
| 348 | + program["start_times"] = start_times |
| 349 | + changed.append("start_times") |
| 350 | + if frequency is not None: |
| 351 | + program["frequency"] = frequency |
| 352 | + changed.append("frequency") |
| 353 | + |
| 354 | + _LOGGER.info( |
| 355 | + "Updating program %s, changed fields: %s", self._program_id, changed |
| 356 | + ) |
| 357 | + await self.coordinator.client.update_program(self._program_id, program) |
| 358 | + |
263 | 359 | async def start_program(self) -> None: |
264 | 360 | """Begins running a program.""" |
265 | 361 | program_payload = self.program_data.get("program") |
|
0 commit comments