diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/custom_components/astralpool_halo_cloud/const.py b/custom_components/astralpool_halo_cloud/const.py index 08c6398..f111e96 100644 --- a/custom_components/astralpool_halo_cloud/const.py +++ b/custom_components/astralpool_halo_cloud/const.py @@ -11,7 +11,7 @@ CONF_AREA_ID = "area_id" CONF_TIME_DRIFT_THRESHOLD_MINUTES = "time_drift_threshold_minutes" -PLATFORMS = ["sensor", "binary_sensor", "select", "number", "button"] +PLATFORMS = ["sensor", "binary_sensor", "select", "number", "button", "switch", "time"] def default_device_name(serial_number: str | None) -> str: diff --git a/custom_components/astralpool_halo_cloud/manifest.json b/custom_components/astralpool_halo_cloud/manifest.json index 8716df1..8831442 100644 --- a/custom_components/astralpool_halo_cloud/manifest.json +++ b/custom_components/astralpool_halo_cloud/manifest.json @@ -16,7 +16,7 @@ } ], "requirements": [ - "pychlorinator_cloud @ git+https://github.com/robmarkoski/pychlorinator-cloud.git@v0.2.3" + "pychlorinator_cloud @ git+https://github.com/robmarkoski/pychlorinator-cloud.git@v0.3.0" ], - "version": "0.2.3" + "version": "0.3.0" } diff --git a/custom_components/astralpool_halo_cloud/select.py b/custom_components/astralpool_halo_cloud/select.py index 2ebb4d5..c4b42f4 100644 --- a/custom_components/astralpool_halo_cloud/select.py +++ b/custom_components/astralpool_halo_cloud/select.py @@ -12,6 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pychlorinator_cloud.timers import TIMER_SPEED_LEVELS + from .const import DOMAIN from .coordinator import HaloCloudCoordinator from .entity import HaloCloudEntity @@ -115,17 +117,18 @@ async def async_setup_entry( ) -> None: """Set up AstralPool Halo Cloud select entities.""" coordinator: HaloCloudCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - HaloModeSelect(coordinator), - HaloPumpSpeedSelect(coordinator), - HaloActionSelect(coordinator, LIGHT_SELECT_DESCRIPTION), - HaloActionSelect(coordinator, BLADE_SELECT_DESCRIPTION), - HaloActionSelect(coordinator, JETS_SELECT_DESCRIPTION), - HaloActionSelect(coordinator, HEATER_SELECT_DESCRIPTION), - HaloAcidDosingSelect(coordinator), - ] - ) + entities: list[SelectEntity] = [ + HaloModeSelect(coordinator), + HaloPumpSpeedSelect(coordinator), + HaloActionSelect(coordinator, LIGHT_SELECT_DESCRIPTION), + HaloActionSelect(coordinator, BLADE_SELECT_DESCRIPTION), + HaloActionSelect(coordinator, JETS_SELECT_DESCRIPTION), + HaloActionSelect(coordinator, HEATER_SELECT_DESCRIPTION), + HaloAcidDosingSelect(coordinator), + ] + for slot_index in range(4): + entities.append(HaloTimerSlotPumpSpeedSelect(coordinator, slot_index)) + async_add_entities(entities) class HaloModeSelect(HaloCloudEntity, SelectEntity): @@ -294,3 +297,53 @@ async def async_select_option(self, option: str) -> None: raise ValueError(f"Invalid option: {option}") await client.disable_acid_dosing(minutes) self.coordinator.async_set_updated_data(client.data) + + +_SPEED_LABEL_TO_CODE = {label: code for code, label in TIMER_SPEED_LEVELS.items() if label != "AI"} +_PUMP_SPEED_OPTIONS = ["Low", "Medium", "High"] + + +class HaloTimerSlotPumpSpeedSelect(HaloCloudEntity, SelectEntity): + """Select entity for the filter pump speed in a single equipment timer slot.""" + + _attr_options = _PUMP_SPEED_OPTIONS + _attr_icon = "mdi:speedometer" + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: HaloCloudCoordinator, slot_index: int) -> None: + self._slot_index = slot_index + super().__init__( + coordinator, + HaloSelectEntityDescription( + key=f"timer_slot_{slot_index}_pump_speed", + name=f"Timer Slot {slot_index + 1} Pump Speed", + ), + ) + + @property + def available(self) -> bool: + return ( + super().available + and self.coordinator.client.data.connected + and self._slot_index in self.coordinator.client.data.equipment_timer_configs + ) + + @property + def current_option(self) -> str | None: + data = self.coordinator.data + if data is None: + return None + config = data.equipment_timer_configs.get(self._slot_index) + if config is None: + return None + return TIMER_SPEED_LEVELS.get(config.get("speed_code", -1)) + + async def async_select_option(self, option: str) -> None: + client = self.coordinator.client + if not client.data.connected: + raise HomeAssistantError("Chlorinator cloud is not connected") + speed_code = _SPEED_LABEL_TO_CODE.get(option) + if speed_code is None: + raise ValueError(f"Invalid pump speed: {option}") + await client.write_timer_slot(self._slot_index, speed_code=speed_code) + self.coordinator.async_set_updated_data(client.data) diff --git a/custom_components/astralpool_halo_cloud/sensor.py b/custom_components/astralpool_halo_cloud/sensor.py index 5887901..9df4322 100644 --- a/custom_components/astralpool_halo_cloud/sensor.py +++ b/custom_components/astralpool_halo_cloud/sensor.py @@ -34,9 +34,9 @@ class HaloSensorEntityDescription(SensorEntityDescription): def _active_timer_count(data: ChlorinatorLiveData) -> int | None: """Return the count of active equipment timer slots when known.""" - if not data.timer_configs: + if not data.equipment_timer_configs: return None - return sum(1 for timer in data.timer_configs.values() if timer.get("active")) + return sum(1 for timer in data.equipment_timer_configs.values() if timer.get("active")) def _timer_summary_value(data: ChlorinatorLiveData) -> str | None: @@ -52,12 +52,12 @@ def _timer_summary_value(data: ChlorinatorLiveData) -> str | None: def _timer_summary_attributes(data: ChlorinatorLiveData) -> dict[str, object]: """Return schedule details for the timer summary sensor.""" - if not data.timer_configs and data.timer_season is None and data.equipment_timer_slots is None: + if not data.equipment_timer_configs and data.timer_season is None and data.equipment_timer_slots is None: return {} ordered_slots = [ - data.timer_configs[index] - for index in sorted(data.timer_configs) + data.equipment_timer_configs[index] + for index in sorted(data.equipment_timer_configs) ] return { "season": data.timer_season, @@ -66,7 +66,7 @@ def _timer_summary_attributes(data: ChlorinatorLiveData) -> dict[str, object]: "equipment_timer_slots": data.equipment_timer_slots, "lighting_timer_slots": data.lighting_timer_slots, "capability_flags": data.timer_capability_flags, - "slot_count_seen": len(data.timer_configs), + "slot_count_seen": len(data.equipment_timer_configs), "slots": ordered_slots, } diff --git a/custom_components/astralpool_halo_cloud/switch.py b/custom_components/astralpool_halo_cloud/switch.py new file mode 100644 index 0000000..c779628 --- /dev/null +++ b/custom_components/astralpool_halo_cloud/switch.py @@ -0,0 +1,130 @@ +"""Switch platform for the AstralPool Halo Cloud integration. + +Provides on/off control for individual equipment timer slots and per-slot heater enable. +""" + +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from pychlorinator_cloud.timers import ENABLES_HEATER + +from .const import DOMAIN +from .coordinator import HaloCloudCoordinator +from .entity import HaloCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AstralPool Halo Cloud switch entities.""" + coordinator: HaloCloudCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[SwitchEntity] = [] + for slot_index in range(4): + entities.append(HaloTimerSlotSwitch(coordinator, slot_index)) + entities.append(HaloTimerSlotHeaterSwitch(coordinator, slot_index)) + async_add_entities(entities) + + +class HaloTimerSlotSwitch(HaloCloudEntity, SwitchEntity): + """Switch to enable or disable a single equipment timer slot.""" + + _attr_icon = "mdi:timer-play-outline" + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: HaloCloudCoordinator, slot_index: int) -> None: + self._slot_index = slot_index + desc = SwitchEntityDescription( + key=f"timer_slot_{slot_index}_active", + name=f"Timer Slot {slot_index + 1} Active", + ) + super().__init__(coordinator, desc) + + @property + def available(self) -> bool: + return ( + super().available + and self.coordinator.client.data.connected + and self._slot_index in self.coordinator.client.data.equipment_timer_configs + ) + + @property + def is_on(self) -> bool | None: + data = self.coordinator.data + if data is None: + return None + config = data.equipment_timer_configs.get(self._slot_index) + return bool(config.get("active")) if config is not None else None + + async def async_turn_on(self, **kwargs: object) -> None: + await self._set_active(True) + + async def async_turn_off(self, **kwargs: object) -> None: + await self._set_active(False) + + async def _set_active(self, enabled: bool) -> None: + client = self.coordinator.client + if not client.data.connected: + raise HomeAssistantError("Chlorinator cloud is not connected") + await client.write_timer_slot(self._slot_index, enabled=enabled) + self.coordinator.async_set_updated_data(client.data) + + +class HaloTimerSlotHeaterSwitch(HaloCloudEntity, SwitchEntity): + """Switch to enable or disable the heater in a single equipment timer slot.""" + + _attr_icon = "mdi:radiator" + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: HaloCloudCoordinator, slot_index: int) -> None: + self._slot_index = slot_index + desc = SwitchEntityDescription( + key=f"timer_slot_{slot_index}_heater", + name=f"Timer Slot {slot_index + 1} Heater", + ) + super().__init__(coordinator, desc) + + @property + def available(self) -> bool: + return ( + super().available + and self.coordinator.client.data.connected + and self._slot_index in self.coordinator.client.data.equipment_timer_configs + ) + + @property + def is_on(self) -> bool | None: + data = self.coordinator.data + if data is None: + return None + config = data.equipment_timer_configs.get(self._slot_index) + if config is None: + return None + return bool(config.get("enables", 0) & ENABLES_HEATER) + + async def async_turn_on(self, **kwargs: object) -> None: + await self._set_heater(True) + + async def async_turn_off(self, **kwargs: object) -> None: + await self._set_heater(False) + + async def _set_heater(self, enable: bool) -> None: + client = self.coordinator.client + if not client.data.connected: + raise HomeAssistantError("Chlorinator cloud is not connected") + existing = client.data.equipment_timer_configs.get(self._slot_index) + if existing is None: + raise HomeAssistantError(f"Timer slot {self._slot_index + 1} config not yet received") + current_enables = existing.get("enables", 0) + if enable: + new_enables = current_enables | ENABLES_HEATER + else: + new_enables = current_enables & ~ENABLES_HEATER + await client.write_timer_slot(self._slot_index, enables=new_enables) + self.coordinator.async_set_updated_data(client.data) diff --git a/custom_components/astralpool_halo_cloud/time.py b/custom_components/astralpool_halo_cloud/time.py new file mode 100644 index 0000000..57a5cac --- /dev/null +++ b/custom_components/astralpool_halo_cloud/time.py @@ -0,0 +1,116 @@ +"""Time platform for the AstralPool Halo Cloud integration. + +Provides start-time and stop-time controls for each equipment timer slot. +""" + +from __future__ import annotations + +import datetime + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import HaloCloudCoordinator +from .entity import HaloCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AstralPool Halo Cloud time entities.""" + coordinator: HaloCloudCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[HaloTimerSlotTime] = [] + for slot_index in range(4): + slot_num = slot_index + 1 + entities.append( + HaloTimerSlotTime( + coordinator, + slot_index=slot_index, + is_start=True, + key=f"timer_slot_{slot_index}_start_time", + name=f"Timer Slot {slot_num} Start", + ) + ) + entities.append( + HaloTimerSlotTime( + coordinator, + slot_index=slot_index, + is_start=False, + key=f"timer_slot_{slot_index}_stop_time", + name=f"Timer Slot {slot_num} Stop", + ) + ) + async_add_entities(entities) + + +class HaloTimerSlotTime(HaloCloudEntity, TimeEntity): + """Time entity controlling the start or stop time for a timer slot.""" + + _attr_icon = "mdi:clock-outline" + _attr_entity_registry_enabled_default = False + + def __init__( + self, + coordinator: HaloCloudCoordinator, + *, + slot_index: int, + is_start: bool, + key: str, + name: str, + ) -> None: + self._slot_index = slot_index + self._is_start = is_start + super().__init__(coordinator, TimeEntityDescription(key=key, name=name)) + + @property + def available(self) -> bool: + return ( + super().available + and self.coordinator.client.data.connected + and self._slot_index in self.coordinator.client.data.equipment_timer_configs + ) + + @property + def native_value(self) -> datetime.time | None: + data = self.coordinator.data + if data is None: + return None + config = data.equipment_timer_configs.get(self._slot_index) + if config is None: + return None + if self._is_start: + hour = config.get("start_hour") + minute = config.get("start_minute") + else: + hour = config.get("stop_hour") + minute = config.get("stop_minute") + if hour is None or minute is None: + return None + try: + return datetime.time(hour, minute) + except ValueError: + return None + + async def async_set_value(self, value: datetime.time) -> None: + client = self.coordinator.client + if not client.data.connected: + raise HomeAssistantError("Chlorinator cloud is not connected") + if self._is_start: + await client.write_timer_slot( + self._slot_index, + start_hour=value.hour, + start_minute=value.minute, + ) + else: + await client.write_timer_slot( + self._slot_index, + stop_hour=value.hour, + stop_minute=value.minute, + ) + self.coordinator.async_set_updated_data(client.data) diff --git a/pychlorinator_cloud/timers.py b/pychlorinator_cloud/timers.py index fc6487a..15be2a0 100644 --- a/pychlorinator_cloud/timers.py +++ b/pychlorinator_cloud/timers.py @@ -1,7 +1,27 @@ -"""Timer models and parsers for Halo cloud timer payloads.""" +"""Timer models, parsers, and write builders for Halo cloud timer payloads. + +Wire format confirmed from decompiled app source (TimeConfigCharacteristic3): + + cmd 0x0193 body (13 bytes, Pack=1): + [0] TimerType 0=Pump, 1=Lighting + [1] TimerIndex slot 0-3 + [2] TimerMode 0=Winter, 1=Summer + [3] TimerEnabled 0/1 + [4-5] Enables uint16 LE equipment bitmask (EnablesValues) + [6] StartMode 0=Normal + [7] StartHour + [8] StartMin + [9] StopMode 0=Normal + [10] StopHour + [11] StopMin + [12] Parameter speed: 0=Low 1=Medium 2=High 3=AI + + Write command: 0x03 + LE16(0x0193) + struct_bytes, padded to 20 bytes total. +""" from __future__ import annotations +import struct from dataclasses import asdict, dataclass from typing import Any @@ -15,13 +35,46 @@ 2: "Summer", } -TIMER_EQUIPMENT_FLAGS = { - 0x04: "Heater", - 0x80: "Blade", +# EnablesValues flags (uint16 bitmask in TimeConfigCharacteristic3.Enables) +ENABLES_POOL_SPA = 0x0001 +ENABLES_FILTER_PUMP = 0x0002 +ENABLES_HEATER = 0x0004 +ENABLES_OUTLET1 = 0x0008 +ENABLES_OUTLET2 = 0x0010 +ENABLES_OUTLET3 = 0x0020 +ENABLES_OUTLET4 = 0x0040 +ENABLES_VALVE1 = 0x0080 +ENABLES_VALVE2 = 0x0100 +ENABLES_VALVE3 = 0x0200 +ENABLES_VALVE4 = 0x0400 +ENABLES_RELAY1 = 0x0800 +ENABLES_RELAY2 = 0x1000 + +ENABLES_LABELS: dict[int, str] = { + ENABLES_POOL_SPA: "PoolSpa", + ENABLES_FILTER_PUMP: "FilterPump", + ENABLES_HEATER: "Heater", + ENABLES_OUTLET1: "Outlet1", + ENABLES_OUTLET2: "Outlet2", + ENABLES_OUTLET3: "Outlet3", + ENABLES_OUTLET4: "Outlet4", + ENABLES_VALVE1: "Valve1", + ENABLES_VALVE2: "Valve2", + ENABLES_VALVE3: "Valve3", + ENABLES_VALVE4: "Valve4", + ENABLES_RELAY1: "Relay1", + ENABLES_RELAY2: "Relay2", } -KNOWN_TIMER_EQUIPMENT_MASK = 0x02 | sum(TIMER_EQUIPMENT_FLAGS) -TIMER_BASE_CLASS_FLAG = 0x02 +TIMER_TYPE_PUMP = 0 +TIMER_TYPE_LIGHTING = 1 + +TIMER_MODE_WINTER = 0 +TIMER_MODE_SUMMER = 1 + +TIMER_START_MODE_NORMAL = 0 +TIMER_STOP_MODE_NORMAL = 0 + TIMER_SPEED_LEVELS = { 0: "Low", 1: "Medium", @@ -29,6 +82,8 @@ 3: "AI", } +_TIMER_CONFIG_STRUCT = struct.Struct(" dict[str, Any]: - """Return a JSON-friendly dictionary.""" data = asdict(self) data["type"] = "timer_capabilities" data["flags"] = list(self.flags) @@ -48,14 +104,13 @@ def to_dict(self) -> dict[str, Any]: @dataclass(slots=True, frozen=True) class TimerSetup: - """Decoded timer setup/profile selection state.""" + """Decoded timer setup/profile selection state (cmd 0x0191).""" season_byte: int season: str raw_bytes: tuple[int, ...] = () def to_dict(self) -> dict[str, Any]: - """Return a JSON-friendly dictionary.""" data = asdict(self) data["type"] = "timer_setup" data["raw_bytes"] = list(self.raw_bytes) @@ -64,14 +119,13 @@ def to_dict(self) -> dict[str, Any]: @dataclass(slots=True, frozen=True) class TimerState: - """Decoded timer state/profile pointer.""" + """Decoded timer state/profile pointer (cmd 0x0192).""" profile_index: int season: str | None = None raw_bytes: tuple[int, ...] = () def to_dict(self) -> dict[str, Any]: - """Return a JSON-friendly dictionary.""" data = asdict(self) data["type"] = "timer_state" data["raw_bytes"] = list(self.raw_bytes) @@ -80,38 +134,38 @@ def to_dict(self) -> dict[str, Any]: @dataclass(slots=True, frozen=True) class TimerConfig: - """Decoded per-slot timer record.""" + """Decoded per-slot timer record (cmd 0x0193).""" + timer_type: int slot_index: int + timer_mode: int active: bool - equipment_flags: int - equipment_enabled: tuple[str, ...] = () - has_base_timer_flag: bool = False - unknown_equipment_flags: tuple[int, ...] = () + enables: int + start_mode: int = 0 start_hour: int = 0 start_minute: int = 0 - start_time: str | None = None + stop_mode: int = 0 stop_hour: int = 0 stop_minute: int = 0 + speed_code: int = 0 + season: str | None = None + start_time: str | None = None stop_time: str | None = None duration_minutes: int | None = None overnight: bool = False - speed_code: int = 0 speed: str | None = None + equipment_enabled: tuple[str, ...] = () raw_bytes: tuple[int, ...] = () def to_dict(self) -> dict[str, Any]: - """Return a JSON-friendly dictionary.""" data = asdict(self) data["type"] = "timer_config" data["equipment_enabled"] = list(self.equipment_enabled) - data["unknown_equipment_flags"] = list(self.unknown_equipment_flags) data["raw_bytes"] = list(self.raw_bytes) return data def _format_time(hour: int, minute: int) -> str | None: - """Format a validated 24h time value.""" if 0 <= hour <= 23 and 0 <= minute <= 59: return f"{hour:02d}:{minute:02d}" return None @@ -123,12 +177,10 @@ def _duration_minutes( stop_hour: int, stop_minute: int, ) -> tuple[int | None, bool]: - """Return duration in minutes and whether the timer crosses midnight.""" start_time = _format_time(start_hour, start_minute) stop_time = _format_time(stop_hour, stop_minute) if start_time is None or stop_time is None: return None, False - start_total = start_hour * 60 + start_minute stop_total = stop_hour * 60 + stop_minute overnight = stop_total < start_total @@ -138,14 +190,18 @@ def _duration_minutes( def parse_timer_capabilities(data: bytes) -> dict[str, Any]: - """Parse cmd 0x0190 timer capabilities.""" + """Parse cmd 0x0190 timer capabilities (TimerCapabilitiesCharacteristic).""" if len(data) < 2: return {"type": "timer_capabilities", "raw": data.hex(), "error": "too short"} + winter_summer = bool(data[2]) if len(data) > 2 else False + dusk_dawn = bool(data[3]) if len(data) > 3 else False return TimerCapabilities( equipment_timer_slots=data[0], lighting_timer_slots=data[1], - flags=tuple(data[2:]), + winter_summer_available=winter_summer, + dusk_dawn_available=dusk_dawn, + flags=tuple(data[4:]), ).to_dict() @@ -176,37 +232,81 @@ def parse_timer_state(data: bytes) -> dict[str, Any]: def parse_timer_config(data: bytes) -> dict[str, Any]: - """Parse cmd 0x0193 per-slot timer config.""" - if len(data) < 13: + """Parse cmd 0x0193 per-slot timer config (TimeConfigCharacteristic3).""" + if len(data) < _TIMER_CONFIG_STRUCT.size: return {"type": "timer_config", "raw": data.hex(), "error": "too short"} - equipment_flags = data[4] - known_equipment = tuple( - name for bit, name in TIMER_EQUIPMENT_FLAGS.items() if equipment_flags & bit - ) - unknown_equipment_flags = tuple( - 1 << bit - for bit in range(8) - if equipment_flags & (1 << bit) and not (KNOWN_TIMER_EQUIPMENT_MASK & (1 << bit)) + ( + timer_type, + slot_index, + timer_mode, + timer_enabled, + enables, + start_mode, + start_hour, + start_min, + stop_mode, + stop_hour, + stop_min, + speed_code, + ) = _TIMER_CONFIG_STRUCT.unpack_from(data) + + equipment_enabled = tuple( + label for bit, label in ENABLES_LABELS.items() if enables & bit ) - duration, overnight = _duration_minutes(data[7], data[8], data[10], data[11]) + duration, overnight = _duration_minutes(start_hour, start_min, stop_hour, stop_min) return TimerConfig( - slot_index=data[0], - active=bool(data[3]), - equipment_flags=equipment_flags, - equipment_enabled=known_equipment, - has_base_timer_flag=bool(equipment_flags & TIMER_BASE_CLASS_FLAG), - unknown_equipment_flags=unknown_equipment_flags, - start_hour=data[7], - start_minute=data[8], - start_time=_format_time(data[7], data[8]), - stop_hour=data[10], - stop_minute=data[11], - stop_time=_format_time(data[10], data[11]), + timer_type=timer_type, + slot_index=slot_index, + timer_mode=timer_mode, + active=bool(timer_enabled), + enables=enables, + start_mode=start_mode, + start_hour=start_hour, + start_minute=start_min, + stop_mode=stop_mode, + stop_hour=stop_hour, + stop_minute=stop_min, + speed_code=speed_code, + season=TIMER_SETUP_SEASONS.get(timer_mode), + start_time=_format_time(start_hour, start_min), + stop_time=_format_time(stop_hour, stop_min), duration_minutes=duration, overnight=overnight, - speed_code=data[12], - speed=TIMER_SPEED_LEVELS.get(data[12], f"Unknown({data[12]})"), + speed=TIMER_SPEED_LEVELS.get(speed_code, f"Unknown({speed_code})"), + equipment_enabled=equipment_enabled, raw_bytes=tuple(data), ).to_dict() + + +def build_timer_config_payload( + slot_index: int, + *, + enabled: bool, + enables: int, + start_hour: int, + start_minute: int, + stop_hour: int, + stop_minute: int, + speed_code: int, + timer_mode: int = TIMER_MODE_WINTER, + start_mode: int = TIMER_START_MODE_NORMAL, + stop_mode: int = TIMER_STOP_MODE_NORMAL, + timer_type: int = TIMER_TYPE_PUMP, +) -> bytes: + """Build the 13-byte TimeConfigCharacteristic3 payload for a cmd 0x0193 write.""" + return _TIMER_CONFIG_STRUCT.pack( + timer_type, + slot_index, + timer_mode, + int(enabled), + enables, + start_mode, + start_hour, + start_minute, + stop_mode, + stop_hour, + stop_minute, + speed_code, + ) diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index f82c868..9fc5065 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -24,7 +24,14 @@ ) from .setpoints import build_setpoint_command from .signalling import map_signalling_failure -from .timers import parse_timer_capabilities, parse_timer_config, parse_timer_setup, parse_timer_state +from .timers import ( + TIMER_MODE_WINTER, + build_timer_config_payload, + parse_timer_capabilities, + parse_timer_config, + parse_timer_setup, + parse_timer_state, +) LOGGER = logging.getLogger(__name__) @@ -111,7 +118,9 @@ class ChlorinatorLiveData: timer_season: Optional[str] = None timer_season_source: Optional[str] = None timer_profile_index: Optional[int] = None - timer_configs: dict[int, dict[str, Any]] = field(default_factory=dict) + # Timer configs keyed by slot_index — always Winter (cloud only streams Winter data) + equipment_timer_configs: dict[int, dict[str, Any]] = field(default_factory=dict) + lighting_timer_configs: dict[int, dict[str, Any]] = field(default_factory=dict) # Raw payloads for debugging raw_payloads: dict[int, bytes] = field(default_factory=dict) @@ -173,6 +182,7 @@ class ChlorinatorLiveData: HEATER_CMD_ID = 0x01F6 TIME_CMD_ID = 0x0002 DATE_CMD_ID = 0x0003 +TIMER_CONFIG_CMD_ID = 0x0193 LIGHT_MODES = {1: "Off", 2: "On", 3: "Auto"} ACTION_MODES = {1: "Off", 2: "Auto", 3: "On"} @@ -628,7 +638,7 @@ async def _request_all_data(self) -> None: """Send ReadForCatchAll requests for data types that don't stream automatically.""" await asyncio.sleep(5) - vomit_cmds = [ + scalar_cmds = [ 9, 100, 101, @@ -636,6 +646,10 @@ async def _request_all_data(self) -> None: 104, 105, 106, + 400, # 0x0190 timer capabilities + 401, # 0x0191 timer setup / season + 402, # 0x0192 timer profile pointer + 403, # 0x0193 timer slot config 600, 601, 602, @@ -644,7 +658,7 @@ async def _request_all_data(self) -> None: ] LOGGER.debug("Requesting initial catch-all data snapshot...") - for cmd_id in vomit_cmds: + for cmd_id in scalar_cmds: if not self._running: break try: @@ -689,6 +703,56 @@ async def request_data(self, cmd_id: int) -> None: read_cmd = bytes([0x02]) + struct.pack(" None: + """Request a fresh snapshot of all timer characteristics.""" + for cmd_id in (0x0190, 0x0191, 0x0192, 0x0193): + await self.request_data(cmd_id) + await _sleep_briefly(0.3) + + async def write_timer_slot( + self, + slot_index: int, + *, + enabled: Optional[bool] = None, + enables: Optional[int] = None, + start_hour: Optional[int] = None, + start_minute: Optional[int] = None, + stop_hour: Optional[int] = None, + stop_minute: Optional[int] = None, + speed_code: Optional[int] = None, + timer_mode: Optional[int] = None, + ) -> None: + """Write a single timer slot config (cmd 0x0193). + + Unspecified fields are taken from the last received config for that slot, + so callers can change only the fields they care about. + """ + if not self._ws or not self.data.connected: + raise RuntimeError("Not connected") + resolved_mode = timer_mode if timer_mode is not None else TIMER_MODE_WINTER + existing = self.data.equipment_timer_configs.get(slot_index) + if existing is None: + raise RuntimeError( + f"Timer slot {slot_index} config not yet received from device. " + "Wait for initial data fetch to complete." + ) + payload = build_timer_config_payload( + slot_index, + enabled=enabled if enabled is not None else existing.get("active", False), + enables=enables if enables is not None else existing.get("enables", 0), + start_hour=start_hour if start_hour is not None else existing.get("start_hour", 0), + start_minute=start_minute if start_minute is not None else existing.get("start_minute", 0), + stop_hour=stop_hour if stop_hour is not None else existing.get("stop_hour", 0), + stop_minute=stop_minute if stop_minute is not None else existing.get("stop_minute", 0), + speed_code=speed_code if speed_code is not None else existing.get("speed_code", 1), + timer_mode=resolved_mode, + ) + await self._send_padded_write( + TIMER_CONFIG_CMD_ID, + payload, + refresh_cmd_ids=(TIMER_CONFIG_CMD_ID,), + ) + async def _refresh_after_action(self) -> None: """Request a bounded state refresh after a control write. @@ -1161,18 +1225,42 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: self.data.timer_season_source = "state" elif parsed.get("type") == "timer_config": slot_index = parsed.get("slot_index") + timer_type = parsed.get("timer_type") if slot_index is not None: - self.data.timer_configs[int(slot_index)] = { - "slot_index": parsed.get("slot_index"), + config_dict = { + "slot_index": slot_index, + "timer_type": timer_type, + "timer_mode": parsed.get("timer_mode"), "active": parsed.get("active"), - "equipment_flags": parsed.get("equipment_flags"), + "enables": parsed.get("enables", 0), "equipment_enabled": parsed.get("equipment_enabled", []), - "has_base_timer_flag": parsed.get("has_base_timer_flag"), - "unknown_equipment_flags": parsed.get("unknown_equipment_flags", []), + "start_hour": parsed.get("start_hour", 0), + "start_minute": parsed.get("start_minute", 0), + "stop_hour": parsed.get("stop_hour", 0), + "stop_minute": parsed.get("stop_minute", 0), "start_time": parsed.get("start_time"), "stop_time": parsed.get("stop_time"), "duration_minutes": parsed.get("duration_minutes"), "overnight": parsed.get("overnight"), "speed": parsed.get("speed"), "speed_code": parsed.get("speed_code"), + "season": parsed.get("season"), } + LOGGER.debug( + "Timer config received: type=%s slot=%s mode=%s(%s) active=%s start=%s:%02d stop=%s:%02d speed=%s enables=0x%04x", + timer_type, + slot_index, + config_dict["timer_mode"], + config_dict["season"], + config_dict["active"], + config_dict["start_hour"], + config_dict["start_minute"], + config_dict["stop_hour"], + config_dict["stop_minute"], + config_dict["speed"], + config_dict["enables"], + ) + if timer_type == 0: + self.data.equipment_timer_configs[int(slot_index)] = config_dict + elif timer_type == 1: + self.data.lighting_timer_configs[int(slot_index)] = config_dict