From 03967fa4661b1733a8edf767dc839d7efcccceb8 Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 09:26:21 +1000 Subject: [PATCH 1/9] feat: full equipment timer read/write support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed write protocol from decompiled app source (TimeConfigCharacteristic3). Fixes parser bugs and adds writable HA entities for timer management. Parser fixes (pychlorinator_cloud/timers.py): - slot_index was reading data[0] (TimerType, always 0) — fixed to data[1] (TimerIndex) - enables was reading 1 byte — fixed to uint16 LE from bytes [4-5] - Updated TIMER_EQUIPMENT_FLAGS to full EnablesValues bitmask from C# source - Added timer_mode / start_mode / stop_mode fields to TimerConfig Library additions (pychlorinator_cloud/websocket_client.py): - Add timer cmd IDs 400-403 to _request_all_data so timers are fetched on connect - Add request_timer_data() for on-demand refresh - Add write_timer_slot() with read-modify-write for unspecified fields - Add TIMER_CONFIG_CMD_ID = 0x0193 HA component additions: - const.py: add "switch" and "time" to PLATFORMS - select.py: HaloTimerSeasonSelect — Summer/Winter profile switcher - switch.py: HaloTimerSlotSwitch — enable/disable each of 4 timer slots - time.py: HaloTimerSlotTime — start and stop time pickers per slot Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + .../astralpool_halo_cloud/const.py | 2 +- .../astralpool_halo_cloud/select.py | 53 +++++ .../astralpool_halo_cloud/switch.py | 81 +++++++ .../astralpool_halo_cloud/time.py | 123 +++++++++++ pychlorinator_cloud/timers.py | 200 +++++++++++++----- pychlorinator_cloud/websocket_client.py | 75 ++++++- 7 files changed, 481 insertions(+), 55 deletions(-) create mode 100644 .gitignore create mode 100644 custom_components/astralpool_halo_cloud/switch.py create mode 100644 custom_components/astralpool_halo_cloud/time.py 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/select.py b/custom_components/astralpool_halo_cloud/select.py index 2ebb4d5..b7c31df 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_MODE_SUMMER, TIMER_MODE_WINTER + from .const import DOMAIN from .coordinator import HaloCloudCoordinator from .entity import HaloCloudEntity @@ -124,6 +126,7 @@ async def async_setup_entry( HaloActionSelect(coordinator, JETS_SELECT_DESCRIPTION), HaloActionSelect(coordinator, HEATER_SELECT_DESCRIPTION), HaloAcidDosingSelect(coordinator), + HaloTimerSeasonSelect(coordinator), ] ) @@ -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) + + +class HaloTimerSeasonSelect(HaloCloudEntity, SelectEntity): + """Select entity for switching between Summer and Winter timer profiles. + + Writing a new season rewrites all known timer slots with the updated + TimerMode byte so the controller activates the matching profile. + """ + + _attr_options = ["Winter", "Summer"] + _SEASON_TO_MODE = {"Winter": TIMER_MODE_WINTER, "Summer": TIMER_MODE_SUMMER} + + @property + def available(self) -> bool: + return super().available and self.coordinator.client.data.connected + + def __init__(self, coordinator: HaloCloudCoordinator) -> None: + super().__init__( + coordinator, + HaloSelectEntityDescription( + key="timer_season_select", + name="Timer Season", + icon="mdi:weather-sunny-alert", + entity_registry_enabled_default=False, + ), + ) + + @property + def current_option(self) -> str | None: + data = self.coordinator.data + if data is None: + return None + return data.timer_season + + 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") + timer_mode = self._SEASON_TO_MODE.get(option) + if timer_mode is None: + raise ValueError(f"Invalid season: {option}") + configs = client.data.timer_configs + if not configs: + raise HomeAssistantError( + "Timer slot configs not yet received — wait for initial data fetch." + ) + for slot_index in sorted(configs): + await client.write_timer_slot(slot_index, timer_mode=timer_mode) + client.data.timer_season = option + self.coordinator.async_set_updated_data(client.data) diff --git a/custom_components/astralpool_halo_cloud/switch.py b/custom_components/astralpool_halo_cloud/switch.py new file mode 100644 index 0000000..845abf7 --- /dev/null +++ b/custom_components/astralpool_halo_cloud/switch.py @@ -0,0 +1,81 @@ +"""Switch platform for the AstralPool Halo Cloud integration. + +Provides on/off control for individual equipment timer slots. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.switch import SwitchEntity +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 + + +@dataclass +class _SlotDesc: + key: str + name: str + + +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] + async_add_entities( + HaloTimerSlotSwitch(coordinator, slot_index) + for slot_index in range(4) + ) + + +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 = _SlotDesc( + 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.timer_configs + ) + + @property + def is_on(self) -> bool | None: + data = self.coordinator.data + if data is None: + return None + config = data.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) diff --git a/custom_components/astralpool_halo_cloud/time.py b/custom_components/astralpool_halo_cloud/time.py new file mode 100644 index 0000000..ebc5db9 --- /dev/null +++ b/custom_components/astralpool_halo_cloud/time.py @@ -0,0 +1,123 @@ +"""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 dataclasses import dataclass + +from homeassistant.components.time import TimeEntity +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 + + +@dataclass +class _SlotTimeDesc: + key: str + name: str + + +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, _SlotTimeDesc(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.timer_configs + ) + + @property + def native_value(self) -> datetime.time | None: + data = self.coordinator.data + if data is None: + return None + config = data.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..b7b3dc2 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -24,7 +24,16 @@ ) 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_SUMMER, + TIMER_MODE_WINTER, + TIMER_TYPE_PUMP, + build_timer_config_payload, + parse_timer_capabilities, + parse_timer_config, + parse_timer_setup, + parse_timer_state, +) LOGGER = logging.getLogger(__name__) @@ -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"} @@ -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 configs (device sends one frame per slot) 600, 601, 602, @@ -689,6 +703,54 @@ 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, + 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") + existing = self.data.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=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=timer_mode if timer_mode is not None else existing.get("timer_mode", TIMER_MODE_WINTER), + ) + 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. @@ -1164,15 +1226,20 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: if slot_index is not None: self.data.timer_configs[int(slot_index)] = { "slot_index": parsed.get("slot_index"), + "timer_type": parsed.get("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"), } From f281027087dc3dd5bb0579e57c978be13b69ff6e Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 09:51:45 +1000 Subject: [PATCH 2/9] fix: use proper HA EntityDescription subclasses for switch and time entities Plain dataclasses lack translation_key and device_class attributes that HA 2026.4 reads from entity_description when computing entity.translation_key and entity.device_class, causing AttributeError during entity registration. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/astralpool_halo_cloud/switch.py | 12 ++---------- custom_components/astralpool_halo_cloud/time.py | 11 ++--------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/custom_components/astralpool_halo_cloud/switch.py b/custom_components/astralpool_halo_cloud/switch.py index 845abf7..b1ac3bc 100644 --- a/custom_components/astralpool_halo_cloud/switch.py +++ b/custom_components/astralpool_halo_cloud/switch.py @@ -5,9 +5,7 @@ from __future__ import annotations -from dataclasses import dataclass - -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -18,12 +16,6 @@ from .entity import HaloCloudEntity -@dataclass -class _SlotDesc: - key: str - name: str - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -45,7 +37,7 @@ class HaloTimerSlotSwitch(HaloCloudEntity, SwitchEntity): def __init__(self, coordinator: HaloCloudCoordinator, slot_index: int) -> None: self._slot_index = slot_index - desc = _SlotDesc( + desc = SwitchEntityDescription( key=f"timer_slot_{slot_index}_active", name=f"Timer Slot {slot_index + 1} Active", ) diff --git a/custom_components/astralpool_halo_cloud/time.py b/custom_components/astralpool_halo_cloud/time.py index ebc5db9..53ff74b 100644 --- a/custom_components/astralpool_halo_cloud/time.py +++ b/custom_components/astralpool_halo_cloud/time.py @@ -6,9 +6,8 @@ from __future__ import annotations import datetime -from dataclasses import dataclass -from homeassistant.components.time import TimeEntity +from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,12 +18,6 @@ from .entity import HaloCloudEntity -@dataclass -class _SlotTimeDesc: - key: str - name: str - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -73,7 +66,7 @@ def __init__( ) -> None: self._slot_index = slot_index self._is_start = is_start - super().__init__(coordinator, _SlotTimeDesc(key=key, name=name)) + super().__init__(coordinator, TimeEntityDescription(key=key, name=name)) @property def available(self) -> bool: From 13757248a014dc1f342dbe26dafab24f83aaaafe Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 10:48:59 +1000 Subject: [PATCH 3/9] fix: separate equipment/lighting timer configs; add per-slot pump speed + heater controls - Split timer_configs into equipment_timer_configs (type=0) and lighting_timer_configs (type=1) so equipment and lighting timer slot 0 no longer overwrite each other - Add debug logging for every timer_config frame received - write_timer_slot now accepts enables param for bitmask control - New HaloTimerSlotHeaterSwitch: toggles ENABLES_HEATER bit per equipment timer slot - New HaloTimerSlotPumpSpeedSelect: Low/Medium/High per equipment timer slot - Update sensor.py timer summary to read from equipment_timer_configs --- .../astralpool_halo_cloud/select.py | 79 +++++++++++++++---- .../astralpool_halo_cloud/sensor.py | 12 +-- .../astralpool_halo_cloud/switch.py | 71 +++++++++++++++-- .../astralpool_halo_cloud/time.py | 4 +- pychlorinator_cloud/websocket_client.py | 29 +++++-- 5 files changed, 161 insertions(+), 34 deletions(-) diff --git a/custom_components/astralpool_halo_cloud/select.py b/custom_components/astralpool_halo_cloud/select.py index b7c31df..06391b3 100644 --- a/custom_components/astralpool_halo_cloud/select.py +++ b/custom_components/astralpool_halo_cloud/select.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pychlorinator_cloud.timers import TIMER_MODE_SUMMER, TIMER_MODE_WINTER +from pychlorinator_cloud.timers import TIMER_MODE_SUMMER, TIMER_MODE_WINTER, TIMER_SPEED_LEVELS from .const import DOMAIN from .coordinator import HaloCloudCoordinator @@ -117,18 +117,19 @@ 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), - HaloTimerSeasonSelect(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), + HaloTimerSeasonSelect(coordinator), + ] + for slot_index in range(4): + entities.append(HaloTimerSlotPumpSpeedSelect(coordinator, slot_index)) + async_add_entities(entities) class HaloModeSelect(HaloCloudEntity, SelectEntity): @@ -338,7 +339,7 @@ async def async_select_option(self, option: str) -> None: timer_mode = self._SEASON_TO_MODE.get(option) if timer_mode is None: raise ValueError(f"Invalid season: {option}") - configs = client.data.timer_configs + configs = client.data.equipment_timer_configs if not configs: raise HomeAssistantError( "Timer slot configs not yet received — wait for initial data fetch." @@ -347,3 +348,53 @@ async def async_select_option(self, option: str) -> None: await client.write_timer_slot(slot_index, timer_mode=timer_mode) client.data.timer_season = option 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 index b1ac3bc..c779628 100644 --- a/custom_components/astralpool_halo_cloud/switch.py +++ b/custom_components/astralpool_halo_cloud/switch.py @@ -1,6 +1,6 @@ """Switch platform for the AstralPool Halo Cloud integration. -Provides on/off control for individual equipment timer slots. +Provides on/off control for individual equipment timer slots and per-slot heater enable. """ from __future__ import annotations @@ -11,6 +11,8 @@ 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 @@ -23,10 +25,11 @@ async def async_setup_entry( ) -> None: """Set up AstralPool Halo Cloud switch entities.""" coordinator: HaloCloudCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - HaloTimerSlotSwitch(coordinator, slot_index) - for slot_index in range(4) - ) + 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): @@ -48,7 +51,7 @@ def available(self) -> bool: return ( super().available and self.coordinator.client.data.connected - and self._slot_index in self.coordinator.client.data.timer_configs + and self._slot_index in self.coordinator.client.data.equipment_timer_configs ) @property @@ -56,7 +59,7 @@ def is_on(self) -> bool | None: data = self.coordinator.data if data is None: return None - config = data.timer_configs.get(self._slot_index) + 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: @@ -71,3 +74,57 @@ async def _set_active(self, enabled: bool) -> None: 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 index 53ff74b..57a5cac 100644 --- a/custom_components/astralpool_halo_cloud/time.py +++ b/custom_components/astralpool_halo_cloud/time.py @@ -73,7 +73,7 @@ def available(self) -> bool: return ( super().available and self.coordinator.client.data.connected - and self._slot_index in self.coordinator.client.data.timer_configs + and self._slot_index in self.coordinator.client.data.equipment_timer_configs ) @property @@ -81,7 +81,7 @@ def native_value(self) -> datetime.time | None: data = self.coordinator.data if data is None: return None - config = data.timer_configs.get(self._slot_index) + config = data.equipment_timer_configs.get(self._slot_index) if config is None: return None if self._is_start: diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index b7b3dc2..4032f91 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -120,7 +120,8 @@ 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) + 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) @@ -714,6 +715,7 @@ async def write_timer_slot( 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, @@ -728,7 +730,7 @@ async def write_timer_slot( """ if not self._ws or not self.data.connected: raise RuntimeError("Not connected") - existing = self.data.timer_configs.get(slot_index) + 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. " @@ -737,7 +739,7 @@ async def write_timer_slot( payload = build_timer_config_payload( slot_index, enabled=enabled if enabled is not None else existing.get("active", False), - enables=existing.get("enables", 0), + 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), @@ -1223,10 +1225,11 @@ 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)] = { + config_dict = { "slot_index": parsed.get("slot_index"), - "timer_type": parsed.get("timer_type"), + "timer_type": timer_type, "timer_mode": parsed.get("timer_mode"), "active": parsed.get("active"), "enables": parsed.get("enables", 0), @@ -1243,3 +1246,19 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: "speed_code": parsed.get("speed_code"), "season": parsed.get("season"), } + LOGGER.debug( + "Timer config received: type=%s slot=%s active=%s start=%s:%s stop=%s:%s speed=%s enables=0x%04x", + timer_type, + slot_index, + 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 From 474acefb4b9909dcfeee882afa0e029dabc3610a Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 10:55:38 +1000 Subject: [PATCH 4/9] debug: add timer_mode and season to timer config log entry --- pychlorinator_cloud/websocket_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index 4032f91..ee241fa 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -1247,9 +1247,11 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: "season": parsed.get("season"), } LOGGER.debug( - "Timer config received: type=%s slot=%s active=%s start=%s:%s stop=%s:%s speed=%s enables=0x%04x", + "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"], From d7a6faa1e43d865f511d77dd398b79cd9245e925 Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 11:10:51 +1000 Subject: [PATCH 5/9] fix: request all timer slot/season combinations; expose active-season configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol requires specifying [timer_type, slot_index, timer_mode] in the read request payload — all-zero payload only returns Winter equipment slot 0. Now request all 16 combinations (pump+lighting × 4 slots × winter+summer). Store all received configs in all_equipment/lighting_timer_configs keyed by (slot_index, timer_mode). equipment/lighting_timer_configs are rebuilt to show only the currently active season's data whenever timer_season is set. write_timer_slot now resolves the active timer_mode from timer_season and reads existing config from all_equipment_timer_configs for that mode. --- .../astralpool_halo_cloud/select.py | 10 ++- pychlorinator_cloud/websocket_client.py | 83 +++++++++++++++++-- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/custom_components/astralpool_halo_cloud/select.py b/custom_components/astralpool_halo_cloud/select.py index 06391b3..810730a 100644 --- a/custom_components/astralpool_halo_cloud/select.py +++ b/custom_components/astralpool_halo_cloud/select.py @@ -339,14 +339,18 @@ async def async_select_option(self, option: str) -> None: timer_mode = self._SEASON_TO_MODE.get(option) if timer_mode is None: raise ValueError(f"Invalid season: {option}") - configs = client.data.equipment_timer_configs - if not configs: + # Collect all known slots across both season configs for this type + all_slots = { + slot for (slot, _mode) in client.data.all_equipment_timer_configs + } or set(client.data.equipment_timer_configs) + if not all_slots: raise HomeAssistantError( "Timer slot configs not yet received — wait for initial data fetch." ) - for slot_index in sorted(configs): + for slot_index in sorted(all_slots): await client.write_timer_slot(slot_index, timer_mode=timer_mode) client.data.timer_season = option + client._rebuild_active_timer_configs() self.coordinator.async_set_updated_data(client.data) diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index ee241fa..f78e225 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -27,6 +27,7 @@ from .timers import ( TIMER_MODE_SUMMER, TIMER_MODE_WINTER, + TIMER_TYPE_LIGHTING, TIMER_TYPE_PUMP, build_timer_config_payload, parse_timer_capabilities, @@ -120,8 +121,12 @@ class ChlorinatorLiveData: timer_season: Optional[str] = None timer_season_source: Optional[str] = None timer_profile_index: Optional[int] = None + # Active-season timer configs (slot_index → config) — rebuilt when season changes equipment_timer_configs: dict[int, dict[str, Any]] = field(default_factory=dict) lighting_timer_configs: dict[int, dict[str, Any]] = field(default_factory=dict) + # All received timer configs keyed by (slot_index, timer_mode) + all_equipment_timer_configs: dict[tuple[int, int], dict[str, Any]] = field(default_factory=dict) + all_lighting_timer_configs: dict[tuple[int, int], dict[str, Any]] = field(default_factory=dict) # Raw payloads for debugging raw_payloads: dict[int, bytes] = field(default_factory=dict) @@ -639,7 +644,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, @@ -650,7 +655,6 @@ async def _request_all_data(self) -> None: 400, # 0x0190 timer capabilities 401, # 0x0191 timer setup / season 402, # 0x0192 timer profile pointer - 403, # 0x0193 timer slot configs (device sends one frame per slot) 600, 601, 602, @@ -659,7 +663,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: @@ -671,6 +675,27 @@ async def _request_all_data(self) -> None: except Exception as err: LOGGER.debug("ReadForCatchAll(%d) failed: %s", cmd_id, err) + # Request all timer slot configs (cmd 0x0193). + # Each request payload identifies [timer_type, slot_index, timer_mode, ...]. + # Request all combinations: pump+lighting × 4 slots × winter+summer. + for t_type in (TIMER_TYPE_PUMP, TIMER_TYPE_LIGHTING): + for t_slot in range(4): + for t_mode in (TIMER_MODE_WINTER, TIMER_MODE_SUMMER): + if not self._running: + break + try: + payload = bytes([t_type, t_slot, t_mode]) + bytes(14) + read_cmd = bytes([0x02]) + struct.pack(" dict[str, Any]: @@ -706,9 +731,41 @@ async def request_data(self, cmd_id: int) -> None: async def request_timer_data(self) -> None: """Request a fresh snapshot of all timer characteristics.""" - for cmd_id in (0x0190, 0x0191, 0x0192, 0x0193): + for cmd_id in (0x0190, 0x0191, 0x0192): await self.request_data(cmd_id) await _sleep_briefly(0.3) + await self._request_all_timer_slot_configs() + + async def _request_all_timer_slot_configs(self) -> None: + """Request cmd 0x0193 for all timer type/slot/mode combinations.""" + for t_type in (TIMER_TYPE_PUMP, TIMER_TYPE_LIGHTING): + for t_slot in range(4): + for t_mode in (TIMER_MODE_WINTER, TIMER_MODE_SUMMER): + payload = bytes([t_type, t_slot, t_mode]) + bytes(14) + read_cmd = bytes([0x02]) + struct.pack(" bool: + """Return True if this timer_mode matches the currently active season.""" + if self.data.timer_season is None: + return True # accept everything until we know the season + active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER + return int(timer_mode) == active_mode + + def _rebuild_active_timer_configs(self) -> None: + """Rebuild active-season timer_configs from the full all_* stores.""" + active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER + self.data.equipment_timer_configs = { + slot: cfg + for (slot, mode), cfg in self.data.all_equipment_timer_configs.items() + if mode == active_mode + } + self.data.lighting_timer_configs = { + slot: cfg + for (slot, mode), cfg in self.data.all_lighting_timer_configs.items() + if mode == active_mode + } async def write_timer_slot( self, @@ -730,7 +787,11 @@ async def write_timer_slot( """ if not self._ws or not self.data.connected: raise RuntimeError("Not connected") - existing = self.data.equipment_timer_configs.get(slot_index) + active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER + resolved_mode = timer_mode if timer_mode is not None else active_mode + existing = self.data.all_equipment_timer_configs.get( + (slot_index, resolved_mode) + ) or 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. " @@ -745,7 +806,7 @@ async def write_timer_slot( 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=timer_mode if timer_mode is not None else existing.get("timer_mode", TIMER_MODE_WINTER), + timer_mode=resolved_mode, ) await self._send_padded_write( TIMER_CONFIG_CMD_ID, @@ -1217,12 +1278,14 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: if season is not None: self.data.timer_season = season self.data.timer_season_source = "setup" + self._rebuild_active_timer_configs() elif parsed.get("type") == "timer_state": self.data.timer_profile_index = parsed.get("profile_index") season = parsed.get("season") if season is not None: self.data.timer_season = season self.data.timer_season_source = "state" + self._rebuild_active_timer_configs() elif parsed.get("type") == "timer_config": slot_index = parsed.get("slot_index") timer_type = parsed.get("timer_type") @@ -1261,6 +1324,10 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: config_dict["enables"], ) if timer_type == 0: - self.data.equipment_timer_configs[int(slot_index)] = config_dict + self.data.all_equipment_timer_configs[(int(slot_index), int(timer_mode))] = config_dict + if self._is_active_timer_mode(timer_mode): + self.data.equipment_timer_configs[int(slot_index)] = config_dict elif timer_type == 1: - self.data.lighting_timer_configs[int(slot_index)] = config_dict + self.data.all_lighting_timer_configs[(int(slot_index), int(timer_mode))] = config_dict + if self._is_active_timer_mode(timer_mode): + self.data.lighting_timer_configs[int(slot_index)] = config_dict From 181a7dc186125c1c27ab3cc3bea75a008952cac2 Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 11:20:25 +1000 Subject: [PATCH 6/9] fix: fall back to available season data; device ignores read payload The cloud API ignores the timer config request payload and always returns the same frame (Winter equipment slot 0). Simplified back to a single request. _rebuild_active_timer_configs now falls back to the other season's data when the active season's config isn't available, so entities stay usable in Summer mode even though the cloud only streams Winter timer configs. --- pychlorinator_cloud/websocket_client.py | 84 ++++++++++++------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index f78e225..c8ef307 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -675,26 +675,17 @@ async def _request_all_data(self) -> None: except Exception as err: LOGGER.debug("ReadForCatchAll(%d) failed: %s", cmd_id, err) - # Request all timer slot configs (cmd 0x0193). - # Each request payload identifies [timer_type, slot_index, timer_mode, ...]. - # Request all combinations: pump+lighting × 4 slots × winter+summer. - for t_type in (TIMER_TYPE_PUMP, TIMER_TYPE_LIGHTING): - for t_slot in range(4): - for t_mode in (TIMER_MODE_WINTER, TIMER_MODE_SUMMER): - if not self._running: - break - try: - payload = bytes([t_type, t_slot, t_mode]) + bytes(14) - read_cmd = bytes([0x02]) + struct.pack(" None: await self._request_all_timer_slot_configs() async def _request_all_timer_slot_configs(self) -> None: - """Request cmd 0x0193 for all timer type/slot/mode combinations.""" - for t_type in (TIMER_TYPE_PUMP, TIMER_TYPE_LIGHTING): - for t_slot in range(4): - for t_mode in (TIMER_MODE_WINTER, TIMER_MODE_SUMMER): - payload = bytes([t_type, t_slot, t_mode]) + bytes(14) - read_cmd = bytes([0x02]) + struct.pack(" bool: - """Return True if this timer_mode matches the currently active season.""" - if self.data.timer_season is None: - return True # accept everything until we know the season - active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER - return int(timer_mode) == active_mode + """Return True — always store received configs; rebuild handles season preference.""" + return True def _rebuild_active_timer_configs(self) -> None: - """Rebuild active-season timer_configs from the full all_* stores.""" + """Rebuild equipment/lighting timer_configs from the full all_* stores. + + Prefers the active season's config for each slot; falls back to the + other season's config when the active season's data hasn't arrived yet + (the cloud API appears to only stream Winter configs regardless of request). + """ active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER - self.data.equipment_timer_configs = { - slot: cfg - for (slot, mode), cfg in self.data.all_equipment_timer_configs.items() - if mode == active_mode - } - self.data.lighting_timer_configs = { - slot: cfg - for (slot, mode), cfg in self.data.all_lighting_timer_configs.items() - if mode == active_mode - } + fallback_mode = TIMER_MODE_WINTER if active_mode == TIMER_MODE_SUMMER else TIMER_MODE_SUMMER + + def _best_configs(store: dict[tuple[int, int], dict]) -> dict[int, dict]: + all_slots = {slot for slot, _ in store} + result: dict[int, dict] = {} + for slot in all_slots: + if (slot, active_mode) in store: + result[slot] = store[(slot, active_mode)] + elif (slot, fallback_mode) in store: + result[slot] = store[(slot, fallback_mode)] + return result + + self.data.equipment_timer_configs = _best_configs(self.data.all_equipment_timer_configs) + self.data.lighting_timer_configs = _best_configs(self.data.all_lighting_timer_configs) async def write_timer_slot( self, From c5d24f419f2bdb6687822e5d41c10959d3cfa39f Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 11:21:45 +1000 Subject: [PATCH 7/9] fix: define timer_mode local variable in _update_data timer_config branch --- pychlorinator_cloud/websocket_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index c8ef307..8c89d28 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -1285,6 +1285,7 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: elif parsed.get("type") == "timer_config": slot_index = parsed.get("slot_index") timer_type = parsed.get("timer_type") + timer_mode = parsed.get("timer_mode") if slot_index is not None: config_dict = { "slot_index": parsed.get("slot_index"), From 33fa6d573486153e91d26e82b1d325dfe5e30c8e Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 11:31:55 +1000 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20remove=20Summer/season=20comple?= =?UTF-8?q?xity=20=E2=80=94=20Winter-only=20timer=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud API only streams Winter timer data regardless of season or request payload. Permanently use Winter mode; remove all_*_timer_configs dicts, _rebuild_active_timer_configs, _is_active_timer_mode, _request_all_timer_slot_configs, and HaloTimerSeasonSelect entity. Timer slot configs now stored directly by slot_index. Co-Authored-By: Claude Sonnet 4.6 --- .../astralpool_halo_cloud/select.py | 57 +------------ pychlorinator_cloud/websocket_client.py | 80 ++----------------- 2 files changed, 9 insertions(+), 128 deletions(-) diff --git a/custom_components/astralpool_halo_cloud/select.py b/custom_components/astralpool_halo_cloud/select.py index 810730a..c4b42f4 100644 --- a/custom_components/astralpool_halo_cloud/select.py +++ b/custom_components/astralpool_halo_cloud/select.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pychlorinator_cloud.timers import TIMER_MODE_SUMMER, TIMER_MODE_WINTER, TIMER_SPEED_LEVELS +from pychlorinator_cloud.timers import TIMER_SPEED_LEVELS from .const import DOMAIN from .coordinator import HaloCloudCoordinator @@ -125,7 +125,6 @@ async def async_setup_entry( HaloActionSelect(coordinator, JETS_SELECT_DESCRIPTION), HaloActionSelect(coordinator, HEATER_SELECT_DESCRIPTION), HaloAcidDosingSelect(coordinator), - HaloTimerSeasonSelect(coordinator), ] for slot_index in range(4): entities.append(HaloTimerSlotPumpSpeedSelect(coordinator, slot_index)) @@ -300,60 +299,6 @@ async def async_select_option(self, option: str) -> None: self.coordinator.async_set_updated_data(client.data) -class HaloTimerSeasonSelect(HaloCloudEntity, SelectEntity): - """Select entity for switching between Summer and Winter timer profiles. - - Writing a new season rewrites all known timer slots with the updated - TimerMode byte so the controller activates the matching profile. - """ - - _attr_options = ["Winter", "Summer"] - _SEASON_TO_MODE = {"Winter": TIMER_MODE_WINTER, "Summer": TIMER_MODE_SUMMER} - - @property - def available(self) -> bool: - return super().available and self.coordinator.client.data.connected - - def __init__(self, coordinator: HaloCloudCoordinator) -> None: - super().__init__( - coordinator, - HaloSelectEntityDescription( - key="timer_season_select", - name="Timer Season", - icon="mdi:weather-sunny-alert", - entity_registry_enabled_default=False, - ), - ) - - @property - def current_option(self) -> str | None: - data = self.coordinator.data - if data is None: - return None - return data.timer_season - - 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") - timer_mode = self._SEASON_TO_MODE.get(option) - if timer_mode is None: - raise ValueError(f"Invalid season: {option}") - # Collect all known slots across both season configs for this type - all_slots = { - slot for (slot, _mode) in client.data.all_equipment_timer_configs - } or set(client.data.equipment_timer_configs) - if not all_slots: - raise HomeAssistantError( - "Timer slot configs not yet received — wait for initial data fetch." - ) - for slot_index in sorted(all_slots): - await client.write_timer_slot(slot_index, timer_mode=timer_mode) - client.data.timer_season = option - client._rebuild_active_timer_configs() - 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"] diff --git a/pychlorinator_cloud/websocket_client.py b/pychlorinator_cloud/websocket_client.py index 8c89d28..9fc5065 100644 --- a/pychlorinator_cloud/websocket_client.py +++ b/pychlorinator_cloud/websocket_client.py @@ -25,10 +25,7 @@ from .setpoints import build_setpoint_command from .signalling import map_signalling_failure from .timers import ( - TIMER_MODE_SUMMER, TIMER_MODE_WINTER, - TIMER_TYPE_LIGHTING, - TIMER_TYPE_PUMP, build_timer_config_payload, parse_timer_capabilities, parse_timer_config, @@ -121,12 +118,9 @@ class ChlorinatorLiveData: timer_season: Optional[str] = None timer_season_source: Optional[str] = None timer_profile_index: Optional[int] = None - # Active-season timer configs (slot_index → config) — rebuilt when season changes + # 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) - # All received timer configs keyed by (slot_index, timer_mode) - all_equipment_timer_configs: dict[tuple[int, int], dict[str, Any]] = field(default_factory=dict) - all_lighting_timer_configs: dict[tuple[int, int], dict[str, Any]] = field(default_factory=dict) # Raw payloads for debugging raw_payloads: dict[int, bytes] = field(default_factory=dict) @@ -655,6 +649,7 @@ async def _request_all_data(self) -> None: 400, # 0x0190 timer capabilities 401, # 0x0191 timer setup / season 402, # 0x0192 timer profile pointer + 403, # 0x0193 timer slot config 600, 601, 602, @@ -675,18 +670,6 @@ async def _request_all_data(self) -> None: except Exception as err: LOGGER.debug("ReadForCatchAll(%d) failed: %s", cmd_id, err) - # Request timer slot configs (cmd 0x0193). - # The device ignores payload and returns its current timer config. - if self._running: - try: - read_cmd = bytes([0x02]) + struct.pack(" dict[str, Any]: @@ -722,46 +705,9 @@ async def request_data(self, cmd_id: int) -> None: async def request_timer_data(self) -> None: """Request a fresh snapshot of all timer characteristics.""" - for cmd_id in (0x0190, 0x0191, 0x0192): + for cmd_id in (0x0190, 0x0191, 0x0192, 0x0193): await self.request_data(cmd_id) await _sleep_briefly(0.3) - await self._request_all_timer_slot_configs() - - async def _request_all_timer_slot_configs(self) -> None: - """Request cmd 0x0193 timer slot configs. - - The device ignores the request payload and streams its current timer - config regardless, so a single request is sufficient. - """ - await self.request_data(0x0193) - await _sleep_briefly(0.3) - - def _is_active_timer_mode(self, timer_mode: Any) -> bool: - """Return True — always store received configs; rebuild handles season preference.""" - return True - - def _rebuild_active_timer_configs(self) -> None: - """Rebuild equipment/lighting timer_configs from the full all_* stores. - - Prefers the active season's config for each slot; falls back to the - other season's config when the active season's data hasn't arrived yet - (the cloud API appears to only stream Winter configs regardless of request). - """ - active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER - fallback_mode = TIMER_MODE_WINTER if active_mode == TIMER_MODE_SUMMER else TIMER_MODE_SUMMER - - def _best_configs(store: dict[tuple[int, int], dict]) -> dict[int, dict]: - all_slots = {slot for slot, _ in store} - result: dict[int, dict] = {} - for slot in all_slots: - if (slot, active_mode) in store: - result[slot] = store[(slot, active_mode)] - elif (slot, fallback_mode) in store: - result[slot] = store[(slot, fallback_mode)] - return result - - self.data.equipment_timer_configs = _best_configs(self.data.all_equipment_timer_configs) - self.data.lighting_timer_configs = _best_configs(self.data.all_lighting_timer_configs) async def write_timer_slot( self, @@ -783,11 +729,8 @@ async def write_timer_slot( """ if not self._ws or not self.data.connected: raise RuntimeError("Not connected") - active_mode = TIMER_MODE_SUMMER if self.data.timer_season == "Summer" else TIMER_MODE_WINTER - resolved_mode = timer_mode if timer_mode is not None else active_mode - existing = self.data.all_equipment_timer_configs.get( - (slot_index, resolved_mode) - ) or self.data.equipment_timer_configs.get(slot_index) + 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. " @@ -1274,21 +1217,18 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: if season is not None: self.data.timer_season = season self.data.timer_season_source = "setup" - self._rebuild_active_timer_configs() elif parsed.get("type") == "timer_state": self.data.timer_profile_index = parsed.get("profile_index") season = parsed.get("season") if season is not None: self.data.timer_season = season self.data.timer_season_source = "state" - self._rebuild_active_timer_configs() elif parsed.get("type") == "timer_config": slot_index = parsed.get("slot_index") timer_type = parsed.get("timer_type") - timer_mode = parsed.get("timer_mode") if slot_index is not None: config_dict = { - "slot_index": parsed.get("slot_index"), + "slot_index": slot_index, "timer_type": timer_type, "timer_mode": parsed.get("timer_mode"), "active": parsed.get("active"), @@ -1321,10 +1261,6 @@ def _update_data(self, parsed: dict[str, Any], raw: bytes) -> None: config_dict["enables"], ) if timer_type == 0: - self.data.all_equipment_timer_configs[(int(slot_index), int(timer_mode))] = config_dict - if self._is_active_timer_mode(timer_mode): - self.data.equipment_timer_configs[int(slot_index)] = config_dict + self.data.equipment_timer_configs[int(slot_index)] = config_dict elif timer_type == 1: - self.data.all_lighting_timer_configs[(int(slot_index), int(timer_mode))] = config_dict - if self._is_active_timer_mode(timer_mode): - self.data.lighting_timer_configs[int(slot_index)] = config_dict + self.data.lighting_timer_configs[int(slot_index)] = config_dict From 0726a5f76baca4780e0008dfff153ec5b77b5a5d Mon Sep 17 00:00:00 2001 From: David Bell Date: Sun, 10 May 2026 12:01:16 +1000 Subject: [PATCH 9/9] chore: bump version to 0.3.0 for equipment timer controls release Co-Authored-By: Claude Sonnet 4.6 --- custom_components/astralpool_halo_cloud/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" }