Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.pyc
__pycache__/
2 changes: 1 addition & 1 deletion custom_components/astralpool_halo_cloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions custom_components/astralpool_halo_cloud/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
75 changes: 64 additions & 11 deletions custom_components/astralpool_halo_cloud/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
12 changes: 6 additions & 6 deletions custom_components/astralpool_halo_cloud/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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,
}

Expand Down
130 changes: 130 additions & 0 deletions custom_components/astralpool_halo_cloud/switch.py
Original file line number Diff line number Diff line change
@@ -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)
116 changes: 116 additions & 0 deletions custom_components/astralpool_halo_cloud/time.py
Original file line number Diff line number Diff line change
@@ -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)
Loading