diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c3f2ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +## [1.2.0] - 2026-06-16 + +### Added +- **`binary_sensor` platform** — three new binary sensors: + - **Frost protection** (`device_class: cold`) — on when the pump's anti-freeze protection (PP07) is active. Reads alarm register 50 bit `0x40`, matching this firmware's register layout. + - **Connectivity** (`device_class: connectivity`) — stays available to report `off` when the pump goes offline. + - **Alarm** (`device_class: problem`) — on when any alarm is active, with the decoded message in the `error_message` attribute. +- **Device registry grouping** — all entities (sensors, climate, numbers, switches, times, binary sensors) now attach to a single Alsavo Pro *device* via a shared `AlsavoProEntity` mixin, with manufacturer/model/serial and live HW/SW versions. Entity IDs and names are unchanged — entities just group under one device card. + +## [1.1.0] - 2026-05-17 + +### Added +- **`number` platform** — five writeable installer settings, with min/max taken directly from the official Android app: + - Defrost in temperature (-30 … 0 °C) + - Defrost out temperature (2 … 30 °C) + - Defrost in time (30 … 90 min) + - Defrost out time (1 … 12 min) + - Water temperature compensation (-9.0 … 9.0 °C, step 0.1) +- **`switch` platform** — three boolean flags on config register 4: + - Timer on enabled + - Timer off enabled + - Pump continuous run (water circulation mode) +- **`time` platform** — daily timer schedule: + - Timer on time (HH:MM) + - Timer off time (HH:MM) +- HVAC modes are now device-type-aware: each Alsavo device type (FreqAll, Single, FixCh, FreqCh, FixAll) only exposes the modes its hardware supports +- 5-second follow-up refresh after every control command — UI reflects the settled pump state without waiting for the next 60 s poll + +### Fixed +- **Set-target/mode/preset writes no longer silently dropped.** The pump only commits config writes on a freshly authenticated session; the persistent-session refactor briefly broke this by reusing a CSID/DSID across writes. Writes now always re-handshake; reads keep reusing the session. +- **`Cold over` sensor showed `65516` instead of `-20`.** The register is a signed 16-bit hysteresis offset but was being read as unsigned. Both `Hot over` and `Cold over` now use the signed interpretation. +- **First query after a write no longer wastes 2 s on a stale-packet retry.** The pump invalidates the session right after a config write; the integration now disconnects proactively so the follow-up read does a fast handshake instead of going through the bad-response → sleep → re-auth path. +- **Startup no longer fails permanently if the pump is briefly unreachable.** Up to 5 consecutive polling failures are tolerated before entities go unavailable. +- **Empty/truncated response packets no longer return zeros silently** — `query_all` now validates the response carries both a status and config section and raises `ConnectionError` otherwise. +- Pre-existing `_followup_cancel` `TypeError` on the second consecutive command (the handle was being called instead of `.cancel()`-ed). +- Follow-up refresh timer is now properly cancelled on config-entry unload, so reloading the integration mid-window no longer leaves a dangling timer firing against a torn-down coordinator. + +### Changed +- Persistent UDP session across the 60 s poll interval (reads); fresh handshake per write. Reduces protocol overhead from ~56 ms per call to ~10 ms per call for reads. +- Dropped dead code: `AlsavoSocketCom.send_packet`, `AlsavoSocketCom.send`, `UDPClient.send`, `UDPClient.SimpleClientProtocol` — only the request/response path is used now. + +## [1.0.5] - 2026-05-13 + +### Fixed +- Replaced deprecated `async_timeout` package with stdlib `asyncio.timeout` (HA 2024.x compatibility) +- Replaced deprecated `async_forward_entry_unload` (called twice) with `async_forward_entry_unloads` accepting a list +- Removed deprecated `CONNECTION_CLASS` from `ConfigFlow` +- Fixed `OptionsFlowHandler` constructor — HA no longer passes `config_entry`; moved `async_get_options_flow` as a `@staticmethod` inside `ConfigFlow` +- Fixed `MissingPasswordValue` exception being raised but never caught in `async_step_user` +- Replaced `asyncio.get_event_loop()` (deprecated in Python 3.10+) with `asyncio.get_running_loop()` in `UDPClient` +- Fixed absolute import `from custom_components.alsavopro.const import ...` to relative `from .const import ...` +- Renamed `async_add_devices` to `async_add_entities` in sensor setup +- Replaced bare `"°C"` strings with `UnitOfTemperature.CELSIUS` constant in sensor definitions +- Fixed log strings with double closing parentheses in `AlsavoPyCtrl` +- Removed unused imports (`CONF_PASSWORD`, `CONF_IP_ADDRESS`, `CONF_PORT`, `CONF_NAME`) from `climate.py` +- Fixed `manifest.json` version to match released version + +## [1.0.4] - 2024 + +### Added +- Additional sensor entities (EEV opening, compressor speed, device status, min/max temperatures, manual settings) + +## [1.0.3] - 2024 + +### Added +- Alarm code registers 48, 49, 50 with full error descriptions +- Error messages sensor aggregating all active alarm codes + +## [1.0.2] - 2024 + +### Added +- Initial HACS release +- Climate entity with heat, cool, auto, and off modes +- Preset modes: Silent, Smart, Powerful +- Temperature sensors: water in, water out, ambient, cold pipe, heating pipe, IPM module, exhaust +- Config sensors: heating/cooling/auto target temperatures, power mode +- Compressor current, frequency, and fan speed sensors diff --git a/README.md b/README.md index fd3188f..28cd4e5 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,27 @@ Custom component for controlling pool heatpumps that uses the Alsavo Pro app in Home Assistant. -**Warning:** This is made by someone with no previous knowledge of Python and no knowledge of Home Assistant framework. And one could argue that both is still the case. Use this at your own risk, and please take backups! +## Why this fork -If some adult with the proper knowledge could improve this, and maybe make it installable with HACS, please feel free to do so! +This fork builds on the original integration with substantially more control coverage and protocol robustness. Comparison against the upstream [`goev`](https://github.com/goev/AlsavoProHomeAssistantIntegration) fork: + +| Capability | This fork | upstream `goev` | +|---|---|---| +| Writable settings | **10 entities** — heat/cool/auto target, defrost in/out temp + time, water compensation, pump-run mode, timer on/off times + enables | 2 — target temp, water calibration | +| `switch` platform | ✅ timer enables, pump continuous-run | ❌ | +| `time` platform | ✅ daily on/off schedule (HH:MM) | ❌ | +| UDP session | **Persistent across the 60 s poll** (~10 ms/read), fresh handshake per write | Full re-auth on every single call (~56 ms each) | +| Control reliability | Writes confirmed against a freshly-authenticated session; 5 s follow-up refresh reads the settled state | — | +| Device-type awareness | **HVAC modes filtered per device type** (Single/FixCh/FreqCh/FixAll/FreqAll) | Fixed mode list | +| `Cold over` reading | **Signed** (correctly shows negative offsets) | Unsigned (shows e.g. 65516 for −20) | +| Frost-protection sensor | Reads PP07 from alarm register 50 (this firmware's layout) | Reads register 49 | +| Robustness | Silent-zero/empty-packet detection, 5-failure offline tolerance, follow-up-timer cleanup on unload | — | +| Config flow | LAN-only (dead cloud-relay option removed) | Still offers the retired cloud endpoint | +| Settings tuning source | All ranges/encodings cross-checked against the official Android APK | — | + +Both forks group entities under a single device (registry `DeviceInfo`) and split them into Sensors / Configuration / Diagnostic categories. + +> Tradeoff for transparency: upstream uses Home Assistant's newer `has_entity_name` naming, while this fork keeps the original `alsavopro__` entity-ID scheme to avoid renaming existing entities and breaking dashboards/automations on upgrade. ## Install #### Manually @@ -12,18 +30,224 @@ In Home Assistant, create a folder under *custom_components* named *AlsavoPro* a Restart Home Assistant and go to *Devices and Services* and press *+Add integration*. Search for *AlsavoPro* and add it. #### HACS Custom Repository -In HACS, add a custom repository and use https://github.com/goev/AlsavoProHomeAssistantIntegration +In HACS, add a custom repository and use https://github.com/laurensdehoorne/AlsavoProHomeAssistantIntegration Download from HACS. Restart Home Assistant and go to *Devices and Services* and press *+Add integration*. Search for *AlsavoPro* and add it. ## Configuration -You must now choose a name for the device. The serial number for the heat pump can be found in the Alsavo Pro app by logging in to the heat pump and pressing the Alsavo Pro-logo in the upper right corner. +You must choose a name for the device. The serial number for the heat pump can be found in the Alsavo Pro app by logging in to the heat pump and pressing the Alsavo Pro-logo in the upper right corner. Password is the same as the one you logged into the Alsavo Pro app with. -Ip-address and port can be one of two: -- If you want to use the cloud, set IP-address to 47.254.157.150 and port to 51192. -- If you want to bypass the cloud, enter the heat pumps ip-address and use port 1194. +For IP-address, enter the heat pump's local IP address on your network, and use port `1194`. The integration talks directly to the pump over UDP — no cloud connection is involved. + +> **Note:** Earlier versions of this README documented a cloud-relay option using a public IP (`47.254.157.150:51192`). That cloud endpoint is no longer reachable (the GalaxyWind / Alsavo regional cloud servers appear to be retired for some regions), and the integration has always worked fine against the pump's LAN IP. The cloud option is no longer recommended or supported. + +## Parameter setting +To access Alsavo Pro heat pump parameters, click "Parameter" in the app and enter password 0757. Key settings include water pump operating modes (P03), input calibration, temperature units, and system diagnostics. These settings allow control over water pump behavior (constant/compressor-dependent) and troubleshooting. + +## Troubleshooting + +### "Offline" in the app but works in HA +The official Alsavo Pro app routes everything through the GalaxyWind cloud (`*.ice.galaxywind.com`). This integration uses direct UDP on your LAN and doesn't need the cloud, so "offline in app, online in HA" is normal — and means local control is healthy. + +### Intermittent HA timeouts or slow updates +If the pump can't reach its cloud server, its WiFi module enters a retry loop that can starve local UDP responses. The European/Australian/Brazilian dispatcher (`47.88.188.100`) currently doesn't respond, and that same IP is hardcoded as a fallback inside the pump firmware — so even DNS-blocking the hostname isn't enough on its own. + +If you see slow or intermittent local responses, add a firewall rule on the IoT network that **REJECTs** (not drops) outbound traffic from the pump to: + +- `47.88.188.100` (hardcoded EU/AU/BR fallback) +- `*.ice.galaxywind.com` if your firewall supports DNS-based rules + +Use REJECT, not DROP — REJECT replies with "unreachable" immediately so the pump gives up fast, while DROP makes it hang on slow timeouts (same problem you're trying to solve). After applying the rule, power-cycle the pump so it discards its current retry state. + +## Alarm codes + +The integration exposes four alarm code sensors (`alarm_code_1` through `alarm_code_4`) that reflect the raw values of the pump's status registers. The `errors` attribute decodes all active alarms into human-readable messages. + +### EE codes (Electrical/Component) — registers 48 & 49 + +| Code | Malfunction | +|------|-------------| +| EE01 | High pressure failure | +| EE02 | Low pressure failure | +| EE03 | Water flow failure | +| EE04 | Water temperature overheating protection (heating mode) | +| EE05 | Exhaust temperature too high | +| EE06 | Controller malfunction or communication failure | +| EE07 | Compressor current protection | +| EE08 | Communication failure (controller ↔ PCB) | +| EE09 | Communication failure (PCB ↔ driver board) | +| EE10 | VDC voltage too high protection | +| EE11 | IPM module protection | +| EE12 | VDC voltage too low protection | +| EE13 | Input current too strong protection | +| EE14 | IPM module thermal circuit abnormal | +| EE15 | IPM module temperature too high protection | +| EE16 | PFC module protection | +| EE17 | DC fan failure | +| EE18 | PFC module thermal circuit abnormal | +| EE19 | PFC module high temperature protection | +| EE20 | Input power failure | +| EE21 | Software control failure | +| EE22 | Current detection circuit failure | +| EE23 | Compressor start failure | +| EE24 | Ambient temperature sensor failure (driving board) | +| EE25 | Compressor phase failure | +| EE26 | 4-way valve reversal failure | +| EE27 | EEPROM data reading failure | +| EE28 | Inter-chip communication failure (main control board) | + +### PP codes (Protection/Sensor) — register 50 + +| Code | Malfunction | +|------|-------------| +| PP01 | Inlet water temperature sensor failure | +| PP02 | Outlet water temperature sensor failure | +| PP03 | Heating coil pipe sensor failure | +| PP04 | Gas return sensor failure | +| PP05 | Ambient temperature sensor failure | +| PP06 | Exhaust temperature sensor failure | +| PP07 | Anti-freezing protection (winter) | +| PP08 | Low ambient temperature protection | +| PP10 | Coil pipe temperature too high protection (cooling mode) | +| PP11 | Water temperature (T2) too low protection (cooling mode) | + +## Climate + +The integration exposes a climate entity with the following HVAC modes: + +| Mode | Description | +|------|-------------| +| Heat | Heating mode | +| Cool | Cooling mode | +| Auto | Automatic mode (heat or cool as needed) | +| Off | Power off | + +The set of available modes is filtered per device type — single-mode units only show Heat, FixCh/FreqCh units show Heat + Cool, FreqAll/FixAll show all three plus Auto. + +Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. Preset selection is only exposed for variable-frequency devices. + +## Controls (writeable settings) + +Beyond the climate entity, the integration also exposes installer-level settings that the official Android app lets you tune. After a setting is changed, the pump's new state is reflected in HA after ~5 seconds. + +### Numbers + +| Entity | Register | Range | Step | +|---|---|---|---| +| Defrost in temperature | 9 | -30 … 0 °C | 1 | +| Defrost out temperature | 10 | 2 … 30 °C | 1 | +| Defrost in time | 12 | 30 … 90 min | 1 | +| Defrost out time | 13 | 1 … 12 min | 1 | +| Water temperature compensation | 11 | -9.0 … 9.0 °C | 0.1 | + +### Switches + +| Entity | Register | Notes | +|---|---|---| +| Timer on enabled | config_sys1 bit 2 | Enables the scheduled daily power-on at *Timer on time* | +| Timer off enabled | config_sys1 bit 7 | Enables the scheduled daily power-off at *Timer off time* | +| Pump continuous run | config_sys1 bit 3 | Water circulation pump runs continuously (vs. cycling with the compressor) | + +### Times + +| Entity | Register | Encoding | +|---|---|---| +| Timer on time | 33 | HH:MM picker (stored as `hour << 8 \| minute`) | +| Timer off time | 34 | same | + +### Tuning for winter operation + +If you keep the heat pump running through winter, the factory defrost defaults often aren't aggressive enough — ice can build up faster than the cycle clears it. Reasonable starting points for Northwest-European climate (-5 … +5 °C ambient): + +| Setting | Default | Winter | +|---|---|---| +| Defrost in temp | -7 °C | **-5 °C** (trigger sooner) | +| Defrost in time | 40 min | **30 min** (react faster) | +| Defrost out temp | 20 °C | **13 °C** (don't overheat the coil) | +| Defrost out time | 12 min | **8 min** | +| Pump continuous run | off | **on** (water keeps circulating through the heat exchanger between cycles) | + +Below ~-7 °C ambient the air-source COP collapses; no defrost setting can compensate, and the practical answer is to winterize the pool and drain the heat exchanger. + +## Sensors + +### Temperature sensors + +| Sensor | Description | +|--------|-------------| +| Water In | Inlet water temperature | +| Water Out | Outlet water temperature | +| Ambient | Ambient air temperature | +| Cold pipe | Cold pipe temperature | +| Heating pipe | Heating pipe temperature | +| IPM module | IPM module temperature | +| Exhaust temperature | Exhaust temperature | +| Compressor input temperature | Compressor input temperature | +| Heating max temperature | Maximum allowed heating setpoint | +| Cooling min temperature | Minimum allowed cooling setpoint | +| Defrost in temperature | Temperature threshold to start defrost | +| Defrost out temperature | Heating pipe temperature to end defrost | +| Water temperature calibration | Offset applied to all temperature readings | +| Heating mode target | Heating setpoint | +| Cooling mode target | Cooling setpoint | +| Auto mode target | Auto mode setpoint | + +### Operational sensors + +| Sensor | Description | +|--------|-------------| +| Fan speed | Fan speed in RPM | +| Compressor | Compressor current (A) | +| Compressor running frequency | Compressor frequency (Hz) | +| Compressor speed setting | 0=off, 1=P1 40Hz … 5=P5 82Hz | +| EEV opening | Electronic exhaust valve opening (0–450) | +| Frequency limit code | Active frequency limit code | +| System status code | System status code | +| System running code | 3=heating, 2=defrost | +| Device status code | Device status code | + +### Config/diagnostic sensors + +| Sensor | Description | +|--------|-------------| +| Power mode | 0=Silent, 1=Smart, 2=Powerful | +| Manual frequency setting | Manual compressor frequency (debug mode) | +| Manual EEV setting | Manual EEV setting (debug mode) | +| Manual fan speed setting | Manual fan speed (debug mode) | +| Defrost in time | Minimum time between defrost cycles (minutes) | +| Defrost out time | Maximum defrost duration (minutes) | +| Hot over | High-temperature hysteresis offset (signed) | +| Cold over | Low-temperature hysteresis offset (signed, can be negative) | +| Current time | Device clock (hi byte=hours, lo byte=minutes) | +| Timer on time | Scheduled power-on time | +| Timer off time | Scheduled power-off time | +| Device type | Device type code | +| Main board HW revision | Hardware revision | +| Main board SW revision | Software revision | +| Manual HW code | Manual hardware code | +| Manual SW code | Manual software code | + +### Alarm sensors + +| Sensor | Description | +|--------|-------------| +| Alarm code 1–4 | Raw alarm register values (registers 48–51) | +| Error messages | Decoded human-readable alarm messages | + +## Binary sensors + +| Sensor | Device class | Description | +|--------|--------------|-------------| +| Frost protection | cold | On when the pump's anti-freeze protection (PP07, register 50 bit `0x40`) is active. Useful as an automation trigger in winter. | +| Connectivity | connectivity | On while the pump answers on the LAN; reports off when it goes offline (stays available so you can alert on it). | +| Alarm | problem | On when any alarm is active; the decoded text is in the `error_message` attribute. | + +## Device grouping + +All entities are attached to a single **Alsavo Pro** device in the registry, so they appear together under one device card (Settings → Devices & Services → *device*), split into Sensors / Configuration / Diagnostic sections. The device exposes the pump's manufacturer, model, serial number, and live hardware/software revisions. + ## AlsavoCtrl This code is very much based on AlsavoCtrl: https://github.com/strandborg/AlsavoCtrl diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index b2e4625..c19b342 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -1,11 +1,19 @@ +import asyncio import hashlib import logging -import random +import secrets import struct from datetime import datetime, timezone -from enum import Enum -from custom_components.alsavopro.const import MODE_TO_CONFIG, NO_WATER_FLUX, WATER_TEMP_TOO_LOW, MAX_UPDATE_RETRIES, \ - MAX_SET_CONFIG_RETRIES + +from .const import ( + MODE_TO_CONFIG, + ALARM_REGISTER_48, + ALARM_REGISTER_49, + ALARM_REGISTER_50, + DEV_TYPE_STATUS_IDX, + DEV_TYPE_FREQALL, + DEV_TYPE_FREQCH, +) from .udpclient import UDPClient _LOGGER = logging.getLogger(__name__) @@ -23,45 +31,76 @@ def __init__(self, name, serial_no, ip_address, port_no, password): self._password = password self._data = QueryResponse(0, 0) self._session = AlsavoSocketCom() - self._set_retries = 0 - self._update_retries = 0 self._online = False + # Serialize read-modify-write on config register 4 (mode/power/timer bits). + self._config4_lock = asyncio.Lock() + + async def _ensure_connected(self): + """Auth and establish a session if we don't have one. Idempotent.""" + await self._session.connect( + self._ip_address, int(self._port_no), int(self._serial_no), self._password + ) + + async def _with_session_retry(self, op, *args): + """ + Run `op(*args)` against the current session. If it fails (likely + expired session or transient socket error), invalidate the session, + re-auth once, and retry. Reads (query_all) reuse the session across + polls; writes (set_config) call disconnect() before invoking this + helper so they always start from a fresh handshake. + """ + try: + await self._ensure_connected() + return await op(*args) + except Exception as first_err: + _LOGGER.debug( + "Session call failed (%s), re-authing and retrying once", first_err + ) + self._session.disconnect() + await asyncio.sleep(2) + await self._ensure_connected() + return await op(*args) async def update(self): - _LOGGER.debug(f"update") + _LOGGER.debug("update") try: - await self._session.connect(self._ip_address, int(self._port_no), int(self._serial_no), self._password) - data = await self._session.query_all() - if data is not None: - self._data = data - except Exception as e: - if self._update_retries < MAX_UPDATE_RETRIES: - self._update_retries += 1 - await self.update() - self._online = True - else: - self._update_retries = 0 - _LOGGER.error(f"Unable to update: {e}") - self._online = False + data = await self._with_session_retry(self._session.query_all) + except Exception: + self._online = False + raise + if data is None: + self._online = False + raise ConnectionError("Empty response from heat pump") + self._data = data + self._online = True async def set_config(self, idx: int, value: int): - _LOGGER.debug(f"set_config({idx}, {value})") + _LOGGER.debug("set_config(%s, %s)", idx, value) + # The pump only commits config writes when made on a freshly + # authenticated session: reusing a CSID/DSID from a prior poll gets + # an ACK back but no state change. Force a fresh handshake before + # every write to match the behaviour of the official Android app. + self._session.disconnect() try: - await self._session.connect(self._ip_address, int(self._port_no), int(self._serial_no), self._password) - await self._session.set_config(idx, value) - except Exception as e: - if self._set_retries < MAX_SET_CONFIG_RETRIES: - self._set_retries += 1 - await self.set_config(idx, value) - self._online = True - else: - self._set_retries = 0 - _LOGGER.error(f"Unable to set config: {idx}, {value} Error: {e}") - self._online = False + await self._with_session_retry(self._session.set_config, idx, value) + self._online = True + except Exception: + self._online = False + raise + # The pump also invalidates the session immediately after a write — + # the next query on the same CSID/DSID returns a truncated packet + # that fails parsing. Drop the session now so the follow-up read + # does a fast fresh handshake instead of paying the failure-retry + # penalty (~2 s sleep + re-auth). + self._session.disconnect() @property def is_online(self) -> bool: - return self._data.parts > 0 + return self._online + + @property + def serial_no(self): + return self._serial_no @property def unique_id(self): @@ -69,7 +108,10 @@ def unique_id(self): @property def target_temperature(self): - return self.get_temperature_from_config(MODE_TO_CONFIG.get(self.operating_mode, 0)) + config_key = MODE_TO_CONFIG.get(self.operating_mode) + if config_key is None: + return None + return self.get_temperature_from_config(config_key) async def set_target_temperature(self, value: float): config_key = MODE_TO_CONFIG.get(self.operating_mode) @@ -82,6 +124,12 @@ def get_status_value(self, idx: int): def get_config_value(self, idx: int): return self._data.get_config_value(idx) + def get_signed_status_value(self, idx: int): + return self._data.get_signed_status_value(idx) + + def get_signed_config_value(self, idx: int): + return self._data.get_signed_config_value(idx) + def get_temperature_from_status(self, idx): return self._data.get_status_temperature_value(idx) @@ -92,74 +140,182 @@ def get_temperature_from_config(self, idx): def water_in_temperature(self): return self.get_temperature_from_status(16) - @property - def water_out_temperature(self): - return self.get_temperature_from_status(17) - - @property - def ambient_temperature(self): - return self.get_temperature_from_status(18) - @property def operating_mode(self): return self._data.get_config_value(4) & 3 @property - def is_timer_on_enabled(self): - return self._data.get_config_value(4) & 4 == 4 + def is_power_on(self): + return self._data.get_config_value(4) & 32 == 32 @property - def water_pump_running_mode(self): - return self._data.get_config_value(4) & 8 == 8 + def power_mode(self): + return self._data.get_config_value(16) @property - def electronic_valve_style(self): - return self._data.get_config_value(4) & 16 == 16 + def dev_type(self): + """Device type from status register (see DEV_TYPE_* in const.py).""" + return self.get_status_value(DEV_TYPE_STATUS_IDX) @property - def is_power_on(self): - return self._data.get_config_value(4) & 32 == 32 + def is_freq_type(self): + """True if the device is variable-frequency (different max heat temp).""" + return self.dev_type in (DEV_TYPE_FREQALL, DEV_TYPE_FREQCH) @property - def power_mode(self): - return self._data.get_config_value(16) + def errors(self): + errors = [] + for reg, alarm_map in [(48, ALARM_REGISTER_48), (49, ALARM_REGISTER_49), (50, ALARM_REGISTER_50)]: + value = self.get_status_value(reg) + for bit, description in alarm_map.items(): + if value & bit: + errors.append(description) + return "\n".join(errors) @property - def is_debug_mode(self): - return self._data.get_config_value(4) & 64 == 64 + def is_frost_protection(self): + """True when the pump's anti-freeze protection (PP07) is active. - @property - def is_timer_off_enabled(self): - return self._data.get_config_value(4) & 128 == 128 + PP07 lives in alarm register 50 bit 0x40 in this firmware's status + layout (see ALARM_REGISTER_50 in const.py) — not register 49 as some + forks assume. + """ + return self.get_status_value(50) & 0x40 == 0x40 @property - def manual_defrost(self): - return self._data.get_config_value(5) & 1 == 1 + def hardware_version(self): + """Main board hardware revision (status register 65).""" + return self.get_status_value(65) @property - def errors(self): - error = "" - if self.get_status_value(48) & 0x4 == 0x4: - error += NO_WATER_FLUX - if self.get_status_value(49) & 0x400 == 0x400: - error += WATER_TEMP_TOO_LOW - return error + def software_version(self): + """Main board software revision (status register 66).""" + return self.get_status_value(66) async def set_power_off(self): - await self.set_config(4, self._data.get_config_value(4) & 0xFFDF) + async with self._config4_lock: + await self.set_config(4, self._data.get_config_value(4) & 0xFFDF) + + async def set_power_on(self): + async with self._config4_lock: + await self.set_config(4, self._data.get_config_value(4) | 0x0020) async def set_cooling_mode(self): - await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 32) + async with self._config4_lock: + await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 32) async def set_heating_mode(self): - await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 33) + async with self._config4_lock: + await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 33) async def set_auto_mode(self): - await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 34) + async with self._config4_lock: + await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 34) async def set_power_mode(self, value: int): await self.set_config(16, value) + # --- register-4 bit toggles (timer enables, pump run mode) ----------------- + # config_sys1 bit layout (from the official Android app's ControlApi): + # bit 0-1: mode (0=cool, 1=heat, 2=auto) — set via set_*_mode + # bit 2 : timer-on enable + # bit 3 : pump run mode (continuous vs cycling) + # bit 5 : power on/off — set via set_power_on/off + # bit 6 : debug mode (intentionally not exposed) + # bit 7 : timer-off enable + + async def _toggle_config4_bit(self, mask: int, enabled: bool): + async with self._config4_lock: + current = self._data.get_config_value(4) + new = (current | mask) if enabled else (current & (~mask & 0xFFFF)) + await self.set_config(4, new) + + @property + def is_timer_on_enabled(self) -> bool: + return bool(self._data.get_config_value(4) & 0x04) + + async def set_timer_on_enabled(self, enabled: bool): + await self._toggle_config4_bit(0x04, enabled) + + @property + def is_timer_off_enabled(self) -> bool: + return bool(self._data.get_config_value(4) & 0x80) + + async def set_timer_off_enabled(self, enabled: bool): + await self._toggle_config4_bit(0x80, enabled) + + @property + def is_pump_run_mode_enabled(self) -> bool: + return bool(self._data.get_config_value(4) & 0x08) + + async def set_pump_run_mode_enabled(self, enabled: bool): + await self._toggle_config4_bit(0x08, enabled) + + # --- timer on/off time (idx 33/34) ---------------------------------------- + # Encoded as (hour << 8) | minute. The current_time status register (idx 32) + # uses the same encoding. + + @staticmethod + def _decode_hhmm(raw: int) -> tuple[int, int]: + return (raw >> 8) & 0xff, raw & 0xff + + @property + def timer_on_hhmm(self) -> tuple[int, int]: + return self._decode_hhmm(self._data.get_config_value(33)) + + @property + def timer_off_hhmm(self) -> tuple[int, int]: + return self._decode_hhmm(self._data.get_config_value(34)) + + async def set_timer_on_hhmm(self, hour: int, minute: int): + await self.set_config(33, (hour << 8) | (minute & 0xff)) + + async def set_timer_off_hhmm(self, hour: int, minute: int): + await self.set_config(34, (hour << 8) | (minute & 0xff)) + + # --- defrost + water compensation ----------------------------------------- + # Bounds from the app (HtcHpParamActivity / TbParamItem): + # defrost in temp (idx 9): -30..0 °C (raw × 10) + # defrost out temp (idx 10): 2..30 °C (raw × 10) + # defrost in time (idx 12): 30..90 min (raw) + # defrost out time (idx 13): 1..12 min (raw) + # water comp (idx 11): -9..9 °C (raw tenths-of-°C, step 0.1) + + @property + def defrost_in_temp(self) -> float: + return self._data.get_config_temperature_value(9) + + @property + def defrost_out_temp(self) -> float: + return self._data.get_config_temperature_value(10) + + @property + def water_compensation(self) -> float: + return self._data.get_signed_config_value(11) / 10.0 + + @property + def defrost_in_time(self) -> int: + return self._data.get_config_value(12) + + @property + def defrost_out_time(self) -> int: + return self._data.get_config_value(13) + + async def set_defrost_in_temp(self, value_c: float): + await self.set_config(9, int(value_c * 10)) + + async def set_defrost_out_temp(self, value_c: float): + await self.set_config(10, int(value_c * 10)) + + async def set_water_compensation(self, value_c: float): + await self.set_config(11, int(value_c * 10)) + + async def set_defrost_in_time(self, minutes: int): + await self.set_config(12, int(minutes)) + + async def set_defrost_out_time(self, minutes: int): + await self.set_config(13, int(minutes)) + @property def name(self): return self._name @@ -228,8 +384,7 @@ def __init__(self, client_token, serial_inv): def pack(self): packed_hdr = self.hdr.pack() packed_uuid = struct.pack('!IIII', *self._uuid) - packed_data = struct.pack('!BBBBIQ', self.act1, self.act2, self.act3, self.act4, self.clientToken, - self.pumpSerial) + packed_uuid + self.timestamp.pack() + packed_data = struct.pack('!BBBBIQ', self.act1, self.act2, self.act3, self.act4, self.clientToken, self.pumpSerial) + packed_uuid + self.timestamp.pack() return packed_hdr + packed_data @@ -291,7 +446,7 @@ def __init__(self, data_type, sub_type, size, start_idx, indices): self.data = [] def get_value(self, idx): - if idx - self.startIdx < 0 or idx - self.startIdx > self.data.__len__(): + if idx - self.startIdx < 0 or idx - self.startIdx >= len(self.data): return 0 return self.data[idx - self.startIdx] @@ -300,6 +455,8 @@ def unpack(data): unpacked_data = struct.unpack('!IHHHH', data[0:12]) obj = Payload(unpacked_data[0], unpacked_data[1], unpacked_data[2], unpacked_data[3], unpacked_data[4]) if obj.subType == 1 or obj.subType == 2: + if len(data) < 12 + obj.size: + raise ValueError(f"Truncated payload: got {len(data)} bytes, need {12 + obj.size}") obj.data = struct.unpack('>' + 'H' * (obj.size // 2), data[12:12 + obj.size]) else: obj.startIdx = 0 @@ -315,22 +472,22 @@ class QueryResponse: def __init__(self, action, parts): self.action = action self.parts = parts - self.__payloads = [] self.__status = None self.__config = None - self.__deviceInfo = None + + @property + def is_valid(self): + return self.__status is not None and self.__config is not None def get_status_value(self, idx: int): if self.__status is None: return 0 - else: - return self.__status.get_value(idx) + return self.__status.get_value(idx) def get_config_value(self, idx: int): if self.__config is None: return 0 - else: - return self.__config.get_value(idx) + return self.__config.get_value(idx) def get_signed_status_value(self, idx: int): unsigned_int = self.get_status_value(idx) @@ -358,15 +515,16 @@ def unpack(data): obj = QueryResponse(unpacked_data[0], unpacked_data[1]) idx = 4 - while idx < data.__len__(): - payload = Payload.unpack(data[idx:]) + while idx < len(data): + try: + payload = Payload.unpack(data[idx:]) + except (ValueError, struct.error) as e: + _LOGGER.debug("Stopping payload parse early: %s", e) + break if payload.subType == 1: obj.__status = payload elif payload.subType == 2: obj.__config = payload - if payload.subType == 3: - obj.__deviceInfo = payload - obj.__payloads.append(payload) idx += payload.size + 8 return obj @@ -379,14 +537,14 @@ def md5_hash(text): return md5.digest() -class ConnectionStatus(Enum): - Disconnected = 0 - Connected = 1 - - class AlsavoSocketCom: - """ Socket communication handler for the Alsavo Pro integration """ - """ Everything is pull-based. """ + """ Socket communication handler for the Alsavo Pro integration. + + Holds a long-lived auth session (CSID/DSID) across calls. The pump + accepts re-used CSID/DSID until it times the session out, at which + point any subsequent packet is dropped with a session-id mismatch + and the caller is expected to re-auth. We re-auth lazily on the next + failure rather than running a keep-alive timer.""" def __init__(self): self.serverToken = None @@ -395,22 +553,31 @@ def __init__(self): self.password = None self.serialQ = None self.clientToken = None - self.lstConfigReqTime = None + self.client = None + + @property + def is_connected(self) -> bool: + return self.CSID is not None and self.DSIS is not None and self.client is not None + + def disconnect(self): + """Drop session state so the next operation re-auths from scratch.""" + self.CSID = None + self.DSIS = None + self.serverToken = None + self.clientToken = None self.client = None async def send_and_receive(self, bytes_to_send): - _LOGGER.debug(f"send_and_receive())") + _LOGGER.debug("send_and_receive()") response = await self.client.send_rcv(bytes_to_send) - _LOGGER.debug(f"Received response") + _LOGGER.debug("Received response") return response - async def send(self, bytes_to_send): - _LOGGER.debug(f"send())") - await self.client.send(bytes_to_send) - async def get_auth_challenge(self): auth_intro = AuthIntro(self.clientToken, self.serialQ) response = await self.send_and_receive(bytes(auth_intro.pack())) + if response is None: + raise ConnectionError("No response to auth challenge (timeout)") return AuthChallenge.unpack(response[0]) async def send_auth_response(self, ctx): @@ -418,40 +585,49 @@ async def send_auth_response(self, ctx): return await self.send_and_receive(resp.pack()) async def send_and_rcv_packet(self, payload: bytes, cmd=0xf4): - _LOGGER.debug(f"send_and_rcv_packet(payload, {cmd})") + _LOGGER.debug("send_and_rcv_packet(payload, %s)", cmd) if self.CSID is not None and self.DSIS is not None: return await self.send_and_receive( - PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, payload.__len__()).pack() + payload + PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, len(payload)).pack() + payload ) return None - async def send_packet(self, payload: bytes, cmd=0xf4): - _LOGGER.debug(f"send_packet(payload, {cmd})") - if self.CSID is not None and self.DSIS is not None: - await self.send(PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, payload.__len__()).pack() + payload) - async def query_all(self): """ Query all information from the heat pump """ _LOGGER.debug("socket.query_all") resp = await self.send_and_rcv_packet(b'\x08\x01\x00\x00\x00\x02\x00\x2e\xff\xff\x00\x00') - self.lstConfigReqTime = datetime.now() if resp is None: - raise Exception("query_all: no response") - return QueryResponse.unpack(resp[0][16:]) + raise ConnectionError("query_all: no response") + result = QueryResponse.unpack(resp[0][16:]) + if not result.is_valid: + raise ConnectionError("query_all: response missing status or config section (unexpected packet?)") + return result async def set_config(self, idx: int, value: int): """ Set configuration values on the heat pump """ - _LOGGER.debug(f"socket.set_config({idx}, {value})") + _LOGGER.debug("socket.set_config(%s, %s)", idx, value) idx_h = ((idx >> 8) & 0xff).to_bytes(1, 'big') idx_l = (idx & 0xff).to_bytes(1, 'big') val_h = ((value >> 8) & 0xff).to_bytes(1, 'big') val_l = (value & 0xff).to_bytes(1, 'big') - await self.send_packet(b'\x09\x01\x00\x00\x00\x02\x00\x2e\x00\x02\x00\x04' + idx_h + idx_l + val_h + val_l) + # Wait for the pump's write-ACK so we know the command landed before + # the caller schedules a follow-up poll. + await self.send_and_rcv_packet(b'\x09\x01\x00\x00\x00\x02\x00\x2e\x00\x02\x00\x04' + idx_h + idx_l + val_h + val_l) async def connect(self, server_ip, server_port, serial, password): + if self.is_connected: + return _LOGGER.debug("Connecting to Alsavo Pro") - - self.clientToken = random.randint(0, 65535) + try: + await self._do_handshake(server_ip, server_port, serial, password) + except Exception: + # Avoid leaving partial CSID/DSID/client state that would make + # is_connected wrongly return True on the next call. + self.disconnect() + raise + + async def _do_handshake(self, server_ip, server_port, serial, password): + self.clientToken = secrets.randbelow(65536) self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) @@ -466,8 +642,10 @@ async def connect(self, server_ip, server_port, serial, password): self.DSIS = auth_challenge.hdr.dsid self.serverToken = auth_challenge.serverToken - _LOGGER.debug(f"Received handshake, CSID={hex(self.CSID)}, DSID={hex(self.DSIS)}, " - f"server token {hex(self.serverToken)}") + _LOGGER.debug( + "Received handshake, CSID=%s, DSID=%s, server token %s", + hex(self.CSID), hex(self.DSIS), hex(self.serverToken), + ) ctx = hashlib.md5() ctx.update(self.clientToken.to_bytes(4, "big")) @@ -476,7 +654,7 @@ async def connect(self, server_ip, server_port, serial, password): response = await self.send_auth_response(ctx) - if response is None or response[0].__len__() == 0: + if response is None or len(response[0]) == 0: raise ConnectionError("Server not responding to auth response, disconnecting.") act = int.from_bytes(response[0][16:20], byteorder='little') diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 7647508..d9b1e3d 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -1,10 +1,13 @@ """Alsavo Pro pool heat pump integration.""" +import asyncio import logging from datetime import timedelta -import async_timeout +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, + UpdateFailed, ) from homeassistant.const import ( @@ -22,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) +PLATFORMS = ["sensor", "binary_sensor", "climate", "number", "switch", "time"] -async def async_setup(hass, config): - return True +# A single update() call performs the full UDP handshake + query. Allow generous +# headroom so a momentarily-slow device doesn't get cancelled mid-handshake. +UPDATE_TIMEOUT = 15 +# Consecutive coordinator failures tolerated before entities go unavailable. +OFFLINE_TOLERANCE = 5 async def async_setup_entry(hass, entry): @@ -36,27 +43,27 @@ async def async_setup_entry(hass, entry): password = entry.data.get(CONF_PASSWORD) data_handler = AlsavoPro(name, serial_no, ip_address, port_no, password) - await data_handler.update() + try: + await data_handler.update() + except Exception as err: + raise ConfigEntryNotReady(f"Cannot connect to AlsavoPro pump: {err}") from err data_coordinator = AlsavoProDataCoordinator(hass, data_handler) - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = data_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator - await hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'climate']) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass, entry): """Unload a config entry.""" - unload_ok = await hass.config_entries.async_forward_entry_unload( - config_entry, "climate" - ) - unload_ok |= await hass.config_entries.async_forward_entry_unload( - config_entry, "sensor" - ) - return unload_ok + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + coordinator = hass.data[DOMAIN].pop(entry.entry_id, None) + if coordinator is not None: + coordinator.shutdown() + return unloaded class AlsavoProDataCoordinator(DataUpdateCoordinator): @@ -65,18 +72,69 @@ def __init__(self, hass, data_handler): super().__init__( hass, _LOGGER, - # Name of the data. For logging purposes. name="AlsavoPro", - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=15), + update_interval=timedelta(seconds=60), ) self.data_handler = data_handler + self._consecutive_failures = 0 + self._followup_cancel = None + + def schedule_followup_refresh(self): + """Schedule a single refresh 5 s after a command to read settled state.""" + if self._followup_cancel is not None: + self._followup_cancel.cancel() + self._followup_cancel = None + + def _fire(): + self._followup_cancel = None + self.hass.async_create_task(self.async_request_refresh()) + + self._followup_cancel = self.hass.loop.call_later(5, _fire) + + def shutdown(self): + """Cancel any pending follow-up timer; called on config-entry unload.""" + if self._followup_cancel is not None: + self._followup_cancel.cancel() + self._followup_cancel = None async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: - async with async_timeout.timeout(10): + async with asyncio.timeout(UPDATE_TIMEOUT): await self.data_handler.update() + self._consecutive_failures = 0 + return self.data_handler + except Exception as err: + self._consecutive_failures += 1 + if self._consecutive_failures < OFFLINE_TOLERANCE: + _LOGGER.debug( + "Alsavo Pro unreachable (attempt %d/%d): %s", + self._consecutive_failures, + OFFLINE_TOLERANCE, + err, + ) return self.data_handler - except Exception as ex: - _LOGGER.debug("_async_update_data timed out") + raise UpdateFailed( + f"Alsavo Pro unreachable after {OFFLINE_TOLERANCE} attempts: {err}" + ) from err + + +class AlsavoProEntity: + """Mixin that attaches every entity to a single Alsavo Pro device. + + Entity classes mix this in alongside CoordinatorEntity; it only adds the + device_info link, so existing entity IDs and names are unchanged — the + entities just get grouped under one device card in the registry. + """ + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, self._data_handler.unique_id)}, + name=self._data_handler.name, + manufacturer="Alsavo / Zealux / Swim&Fun", + model="Pro pool heat pump", + serial_number=str(self._data_handler.serial_no), + hw_version=str(self._data_handler.hardware_version), + sw_version=str(self._data_handler.software_version), + ) diff --git a/custom_components/alsavopro/binary_sensor.py b/custom_components/alsavopro/binary_sensor.py new file mode 100644 index 0000000..6f2871d --- /dev/null +++ b/custom_components/alsavopro/binary_sensor.py @@ -0,0 +1,93 @@ +"""Binary sensors for Alsavo Pro: connectivity, frost protection, alarm.""" +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator, AlsavoProEntity +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + AlsavoProConnectivitySensor(coordinator), + AlsavoProFrostProtectionSensor(coordinator), + AlsavoProAlarmSensor(coordinator), + ] + ) + + +class _AlsavoProBinarySensorBase(AlsavoProEntity, CoordinatorEntity, BinarySensorEntity): + """Shared plumbing for the Alsavo Pro binary sensors.""" + + _label = "" + _key = "" + + def __init__(self, coordinator: AlsavoProDataCoordinator): + super().__init__(coordinator) + self.data_coordinator = coordinator + self._data_handler = coordinator.data_handler + + @property + def name(self) -> str: + return f"{DOMAIN}_{self._data_handler.name}_{self._label}" + + @property + def unique_id(self) -> str: + return f"{self._data_handler.unique_id}_{self._key}" + + +class AlsavoProConnectivitySensor(_AlsavoProBinarySensorBase): + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _label = "Connectivity" + _key = "connectivity" + + @property + def available(self) -> bool: + # The connectivity sensor must stay available to report "off" when the + # pump goes offline — otherwise it would disappear exactly when it + # carries the most useful information. + return True + + @property + def is_on(self) -> bool: + return self._data_handler.is_online + + +class AlsavoProFrostProtectionSensor(_AlsavoProBinarySensorBase): + _attr_device_class = BinarySensorDeviceClass.COLD + _attr_icon = "mdi:snowflake-alert" + _label = "Frost protection" + _key = "frost_protection" + + @property + def available(self) -> bool: + return self._data_handler.is_online + + @property + def is_on(self) -> bool: + return self._data_handler.is_frost_protection + + +class AlsavoProAlarmSensor(_AlsavoProBinarySensorBase): + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _label = "Alarm" + _key = "alarm" + + @property + def available(self) -> bool: + return self._data_handler.is_online + + @property + def is_on(self) -> bool: + return bool(self._data_handler.errors) + + @property + def extra_state_attributes(self): + return {"error_message": self._data_handler.errors} diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 858e143..55ea9af 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -1,8 +1,6 @@ -"""Support for Alsavo Pro wifi-enabled pool heaters.""" import logging from homeassistant.components.climate import ( - PLATFORM_SCHEMA, ClimateEntity, ClimateEntityFeature, HVACMode @@ -10,168 +8,170 @@ from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_IP_ADDRESS, - CONF_PORT, - CONF_NAME, - PRECISION_TENTHS, UnitOfTemperature, ) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .const import ( DOMAIN, - POWER_MODE_MAP + POWER_MODE_MAP, + DEV_TYPE_FREQALL, + DEV_TYPE_SINGLE, + DEV_TYPE_FIXCH, + DEV_TYPE_FREQCH, + DEV_TYPE_FIXALL, + TEMP_COLD_MIN, + TEMP_COLD_MAX, + TEMP_HOT_MIN, + TEMP_HOT_FREQ_MAX, + TEMP_HOT_FIX_MAX, ) _LOGGER = logging.getLogger(__name__) +# Inverse of POWER_MODE_MAP — translates HA preset name → register value. +_PRESET_TO_POWER_MODE = {name: code for code, name in POWER_MODE_MAP.items()} + +# HVAC modes supported per device type (excluding OFF, which is always available). +# Derived from the official Android app's onClickMod() handler. +_HVAC_MODES_BY_DEV_TYPE = { + DEV_TYPE_FREQALL: [HVACMode.COOL, HVACMode.HEAT, HVACMode.AUTO], + DEV_TYPE_SINGLE: [HVACMode.HEAT], + DEV_TYPE_FIXCH: [HVACMode.COOL, HVACMode.HEAT], + DEV_TYPE_FREQCH: [HVACMode.COOL, HVACMode.HEAT], + DEV_TYPE_FIXALL: [HVACMode.COOL, HVACMode.HEAT, HVACMode.AUTO], +} + async def async_setup_entry(hass, entry, async_add_entities): async_add_entities([AlsavoProClimate(hass.data[DOMAIN][entry.entry_id])]) -class AlsavoProClimate(CoordinatorEntity, ClimateEntity): +class AlsavoProClimate(AlsavoProEntity, CoordinatorEntity, ClimateEntity): """ Climate platform for Alsavo Pro pool heater """ + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1.0 + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + def __init__(self, coordinator: AlsavoProDataCoordinator): """Initialize the heater.""" super().__init__(coordinator) - self.coordinator = coordinator - self._data_handler = self.coordinator.data_handler + self._data_handler = coordinator.data_handler self._name = self._data_handler.name - @property - def supported_features(self): - """Return the list of supported features.""" - return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - @property def unique_id(self): - """Return a unique ID.""" return self._data_handler.unique_id @property def name(self): - """Return the name of the device, if any.""" return self._name @property def available(self) -> bool: - """Return True if roller and hub is available.""" return self._data_handler.is_online @property def hvac_mode(self): - """Return hvac operation i.e. heat, cool mode.""" + if not self._data_handler.is_power_on: + return HVACMode.OFF operating_mode_map = { 0: HVACMode.COOL, 1: HVACMode.HEAT, - 2: HVACMode.AUTO + 2: HVACMode.AUTO, } + return operating_mode_map.get(self._data_handler.operating_mode, HVACMode.OFF) - if not self._data_handler.is_power_on: - return HVACMode.OFF - - return operating_mode_map.get(self._data_handler.operating_mode) + @property + def hvac_modes(self): + modes = _HVAC_MODES_BY_DEV_TYPE.get( + self._data_handler.dev_type, + [HVACMode.COOL, HVACMode.HEAT, HVACMode.AUTO], + ) + return [HVACMode.OFF, *modes] @property def preset_mode(self): - """Return Preset modes silent, smart mode.""" return POWER_MODE_MAP.get(self._data_handler.power_mode) + @property + def preset_modes(self): + # Preset modes only apply to variable-frequency devices. + if not self._data_handler.is_freq_type: + return [] + return list(POWER_MODE_MAP.values()) + @property def icon(self): - """Return nice icon for heater.""" hvac_mode_icons = { HVACMode.HEAT: "mdi:fire", HVACMode.COOL: "mdi:snowflake", - HVACMode.AUTO: "mdi:refresh-auto" + HVACMode.AUTO: "mdi:autorenew", } - return hvac_mode_icons.get(self.hvac_mode, "mdi:hvac-off") - @property - def hvac_modes(self): - """Return the list of available hvac operation modes.""" - return [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF] + async def async_turn_on(self): + await self._data_handler.set_power_on() + self.coordinator.schedule_followup_refresh() - @property - def preset_modes(self): - """Return the list of available hvac operation modes.""" - return ['Silent', 'Smart', 'Powerful'] + async def async_turn_off(self): + await self._data_handler.set_power_off() + self.coordinator.schedule_followup_refresh() async def async_set_hvac_mode(self, hvac_mode): - """Set hvac mode.""" hvac_mode_actions = { HVACMode.OFF: self._data_handler.set_power_off, HVACMode.COOL: self._data_handler.set_cooling_mode, HVACMode.HEAT: self._data_handler.set_heating_mode, - HVACMode.AUTO: self._data_handler.set_auto_mode + HVACMode.AUTO: self._data_handler.set_auto_mode, } - action = hvac_mode_actions.get(hvac_mode) - if action: - await action() - await self.coordinator.async_request_refresh() + if action is None: + return + await action() + self.coordinator.schedule_followup_refresh() async def async_set_preset_mode(self, preset_mode): - """Set hvac preset mode.""" - preset_mode_to_power_mode = { - 'Silent': 0, # Silent - 'Smart': 1, # Smart - 'Powerful': 2 # Powerful - } - - power_mode = preset_mode_to_power_mode.get(preset_mode) - if power_mode is not None: - await self._data_handler.set_power_mode(power_mode) - await self.coordinator.async_request_refresh() - - @property - def temperature_unit(self): - """Return the unit of measurement which this device uses.""" - return UnitOfTemperature.CELSIUS + power_mode = _PRESET_TO_POWER_MODE.get(preset_mode) + if power_mode is None: + return + await self._data_handler.set_power_mode(power_mode) + self.coordinator.schedule_followup_refresh() @property def min_temp(self): - """Return the minimum temperature.""" - return self._data_handler.get_temperature_from_status(56) + if self.hvac_mode == HVACMode.HEAT: + return TEMP_HOT_MIN + return TEMP_COLD_MIN @property def max_temp(self): - """Return the maximum temperature.""" - return self._data_handler.get_temperature_from_status(55) + if self.hvac_mode == HVACMode.COOL: + return TEMP_COLD_MAX + # Heat and Auto share the same upper bound; it varies by device type. + return TEMP_HOT_FREQ_MAX if self._data_handler.is_freq_type else TEMP_HOT_FIX_MAX @property def current_temperature(self): - """Return the current temperature.""" return self._data_handler.water_in_temperature @property def target_temperature(self): - """Return the temperature we try to reach.""" return self._data_handler.target_temperature - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return PRECISION_TENTHS - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return await self._data_handler.set_target_temperature(temperature) - await self.coordinator.async_request_refresh() - - async def async_update(self): - """Get the latest data.""" - self._data_handler = self.coordinator.data_handler + self.coordinator.schedule_followup_refresh() diff --git a/custom_components/alsavopro/config_flow.py b/custom_components/alsavopro/config_flow.py index 60e47b1..5ecf47f 100755 --- a/custom_components/alsavopro/config_flow.py +++ b/custom_components/alsavopro/config_flow.py @@ -1,21 +1,19 @@ """Adds config flow for AlsavoPro pool heater integration.""" import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( CONF_PASSWORD, CONF_NAME, CONF_IP_ADDRESS, - CONF_PORT + CONF_PORT, ) from .const import ( SERIAL_NO, - DOMAIN + DOMAIN, ) -# _LOGGER = logging.getLogger(__name__) - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): str, @@ -27,64 +25,36 @@ ) -async def validate_input(hass: core.HomeAssistant, name, serial_no, ip_address, port_no, password): - """Validate the user input allows us to connect.""" - - # Pre-validation for missing mandatory fields - if not name: - raise MissingNameValue("The 'name' field is required.") - if not password: - raise MissingPasswordValue("The 'password' field is required.") - - for entry in hass.config_entries.async_entries(DOMAIN): - if any([ - entry.data[SERIAL_NO] == serial_no, - entry.data[CONF_NAME] == name, - entry.data[CONF_IP_ADDRESS] == ip_address, - entry.data[CONF_PORT] == port_no - ]): - raise AlreadyConfigured("An entry with the given details already exists.") - - # Additional validations (if any) go here... - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] """Handle a config flow for Alsavo Pro pool heater integration.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} if user_input is not None: - try: - name = user_input[CONF_NAME] - serial_no = user_input[SERIAL_NO] - ip_address = user_input[CONF_IP_ADDRESS] - port_no = user_input[CONF_PORT] - password = user_input[CONF_PASSWORD].replace(" ", "") - await validate_input(self.hass, name, serial_no, ip_address, port_no, password) - unique_id = f"{name}-{serial_no}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=unique_id, - data={CONF_NAME: name, - SERIAL_NO: serial_no, - CONF_IP_ADDRESS: ip_address, - CONF_PORT: port_no, - CONF_PASSWORD: password}, - ) - - except AlreadyConfigured: - return self.async_abort(reason="already_configured") - except CannotConnect: - errors["base"] = "connection_error" - except MissingNameValue: - errors["base"] = "missing_name" + name = user_input[CONF_NAME] + serial_no = user_input[SERIAL_NO] + ip_address = user_input[CONF_IP_ADDRESS] + port_no = user_input[CONF_PORT] + password = user_input[CONF_PASSWORD].replace(" ", "") + + # Serial number is the only stable identifier for the device. + await self.async_set_unique_id(serial_no) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{name} ({serial_no})", + data={ + CONF_NAME: name, + SERIAL_NO: serial_no, + CONF_IP_ADDRESS: ip_address, + CONF_PORT: port_no, + CONF_PASSWORD: password, + }, + ) return self.async_show_form( step_id="user", @@ -92,33 +62,27 @@ async def async_step_user(self, user_input=None): errors=errors, ) + @staticmethod + @callback + def async_get_options_flow(config_entry): + return OptionsFlowHandler(config_entry) + class OptionsFlowHandler(config_entries.OptionsFlow): + def __init__(self, config_entry): + self._entry = config_entry + async def async_step_init(self, user_input=None): + if user_input is not None: + new_password = user_input.get(CONF_PASSWORD, "").replace(" ", "") + if new_password: + new_data = {**self._entry.data, CONF_PASSWORD: new_password} + self.hass.config_entries.async_update_entry(self._entry, data=new_data) + return self.async_create_entry(title="", data={}) + return self.async_show_form( step_id="init", data_schema=vol.Schema({ vol.Optional(CONF_PASSWORD): str, }), ) - - -@callback -def async_get_options_flow(config_entry): - return OptionsFlowHandler(config_entry) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class AlreadyConfigured(exceptions.HomeAssistantError): - """Error to indicate host is already configured.""" - - -class MissingNameValue(exceptions.HomeAssistantError): - """Error to indicate name is missing.""" - - -class MissingPasswordValue(exceptions.HomeAssistantError): - """Error to indicate name is missing.""" diff --git a/custom_components/alsavopro/const.py b/custom_components/alsavopro/const.py index d89953e..8412660 100755 --- a/custom_components/alsavopro/const.py +++ b/custom_components/alsavopro/const.py @@ -13,10 +13,70 @@ 1: 1, # Heat 2: 3} # Auto -# Errors -NO_WATER_FLUX = "No water flux or water flow switch failure.\n\r" -WATER_TEMP_TOO_LOW = "Water temperature (T2) too low protection under cooling mode.\n\r" +# Alarm code bit maps per status register +ALARM_REGISTER_48 = { + 0x0001: "EE01: High pressure failure", + 0x0002: "EE02: Low pressure failure", + 0x0004: "EE03: Water flow failure", + 0x0008: "EE04: Water temperature overheating protection (heating mode)", + 0x0010: "EE05: Exhaust temperature too high", + 0x0020: "EE06: Controller malfunction or communication failure", + 0x0040: "EE07: Compressor current protection", + 0x0080: "EE08: Communication failure (controller ↔ PCB)", + 0x0100: "EE09: Communication failure (PCB ↔ driver board)", + 0x0200: "EE10: VDC voltage too high protection", + 0x0400: "EE11: IPM module protection", + 0x0800: "EE12: VDC voltage too low protection", + 0x1000: "EE13: Input current too strong protection", + 0x2000: "EE14: IPM module thermal circuit abnormal", + 0x4000: "EE15: IPM module temperature too high protection", + 0x8000: "EE16: PFC module protection", +} + +ALARM_REGISTER_49 = { + 0x0001: "EE17: DC fan failure", + 0x0002: "EE18: PFC module thermal circuit abnormal", + 0x0004: "EE19: PFC module high temperature protection", + 0x0008: "EE20: Input power failure", + 0x0010: "EE21: Software control failure", + 0x0020: "EE22: Current detection circuit failure", + 0x0040: "EE23: Compressor start failure", + 0x0080: "EE24: Ambient temperature sensor failure (driving board)", + 0x0100: "EE25: Compressor phase failure", + 0x0200: "EE26: 4-way valve reversal failure", + 0x0400: "EE27: EEPROM data reading failure", + 0x0800: "EE28: Inter-chip communication failure (main control board)", +} + +ALARM_REGISTER_50 = { + 0x0001: "PP01: Inlet water temperature sensor failure", + 0x0002: "PP02: Outlet water temperature sensor failure", + 0x0004: "PP03: Heating coil pipe sensor failure", + 0x0008: "PP04: Gas return sensor failure", + 0x0010: "PP05: Ambient temperature sensor failure", + 0x0020: "PP06: Exhaust temperature sensor failure", + 0x0040: "PP07: Anti-freezing protection (winter)", + 0x0080: "PP08: Low ambient temperature protection", + 0x0200: "PP10: Coil pipe temperature too high protection (cooling mode)", + 0x0400: "PP11: Water temperature (T2) too low protection (cooling mode)", +} + +# Device type (status register 64). Values reverse-engineered from the official app: +# 0 = FREQALL (variable-frequency, all modes) +# 1 = SINGLE (heat-only) +# 2 = FIXCH (fixed-speed, cool + heat) +# 3 = FREQCH (variable-frequency, cool + heat) +# 4 = FIXALL (fixed-speed, all modes) +DEV_TYPE_STATUS_IDX = 64 +DEV_TYPE_FREQALL = 0 +DEV_TYPE_SINGLE = 1 +DEV_TYPE_FIXCH = 2 +DEV_TYPE_FREQCH = 3 +DEV_TYPE_FIXALL = 4 -# Max retries -MAX_UPDATE_RETRIES = 10 -MAX_SET_CONFIG_RETRIES = 10 +# Hardcoded temperature limits used by the official app (°C). +TEMP_COLD_MIN = 6 +TEMP_COLD_MAX = 35 +TEMP_HOT_MIN = 15 +TEMP_HOT_FREQ_MAX = 41 # variable-frequency devices +TEMP_HOT_FIX_MAX = 42 # fixed-speed devices diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index df4a841..48f1df2 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -1,9 +1,9 @@ { "domain": "alsavopro", "name": "AlsavoPro", - "documentation": "https://github.com/goev/AlsavoProHomeAssistantIntegration", + "documentation": "https://github.com/laurensdehoorne/AlsavoProHomeAssistantIntegration", "requirements": [], "codeowners": [], "config_flow": true, - "version": "0.0.1" + "version": "1.2.0" } diff --git a/custom_components/alsavopro/number.py b/custom_components/alsavopro/number.py new file mode 100644 index 0000000..bb135d8 --- /dev/null +++ b/custom_components/alsavopro/number.py @@ -0,0 +1,137 @@ +"""Number entities for Alsavo Pro installer-level settings.""" +from dataclasses import dataclass +from typing import Awaitable, Callable + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberMode, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator, AlsavoProEntity +from .AlsavoPyCtrl import AlsavoPro +from .const import DOMAIN + + +@dataclass(frozen=True, kw_only=True) +class AlsavoNumberSpec: + key: str + name: str + icon: str + min_value: float + max_value: float + step: float + unit: str + device_class: NumberDeviceClass | None + getter: Callable[[AlsavoPro], float] + setter: Callable[[AlsavoPro, float], Awaitable[None]] + + +# Bounds match the official Android app (HtcHpParamActivity / TbParamItem). +NUMBER_SPECS: tuple[AlsavoNumberSpec, ...] = ( + AlsavoNumberSpec( + key="defrost_in_temp", + name="Defrost in temperature", + icon="mdi:snowflake-melt", + min_value=-30, + max_value=0, + step=1, + unit=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + getter=lambda dh: dh.defrost_in_temp, + setter=lambda dh, v: dh.set_defrost_in_temp(v), + ), + AlsavoNumberSpec( + key="defrost_out_temp", + name="Defrost out temperature", + icon="mdi:snowflake-off", + min_value=2, + max_value=30, + step=1, + unit=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + getter=lambda dh: dh.defrost_out_temp, + setter=lambda dh, v: dh.set_defrost_out_temp(v), + ), + AlsavoNumberSpec( + key="defrost_in_time", + name="Defrost in time", + icon="mdi:timer-sand", + min_value=30, + max_value=90, + step=1, + unit=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + getter=lambda dh: dh.defrost_in_time, + setter=lambda dh, v: dh.set_defrost_in_time(int(v)), + ), + AlsavoNumberSpec( + key="defrost_out_time", + name="Defrost out time", + icon="mdi:timer-sand-complete", + min_value=1, + max_value=12, + step=1, + unit=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + getter=lambda dh: dh.defrost_out_time, + setter=lambda dh, v: dh.set_defrost_out_time(int(v)), + ), + AlsavoNumberSpec( + key="water_compensation", + name="Water temperature compensation", + icon="mdi:thermometer-plus", + min_value=-9.0, + max_value=9.0, + step=0.1, + unit=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + getter=lambda dh: dh.water_compensation, + setter=lambda dh, v: dh.set_water_compensation(v), + ), +) + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator: AlsavoProDataCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(AlsavoProNumber(coordinator, spec) for spec in NUMBER_SPECS) + + +class AlsavoProNumber(AlsavoProEntity, CoordinatorEntity, NumberEntity): + _attr_mode = NumberMode.BOX + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoNumberSpec): + super().__init__(coordinator) + self._coordinator = coordinator + self._data_handler: AlsavoPro = coordinator.data_handler + self._spec = spec + self._attr_icon = spec.icon + self._attr_native_min_value = spec.min_value + self._attr_native_max_value = spec.max_value + self._attr_native_step = spec.step + self._attr_native_unit_of_measurement = spec.unit + self._attr_device_class = spec.device_class + + @property + def name(self) -> str: + return f"{DOMAIN}_{self._data_handler.name}_{self._spec.name}" + + @property + def unique_id(self) -> str: + return f"{self._data_handler.unique_id}_{self._spec.key}" + + @property + def available(self) -> bool: + return self._data_handler.is_online + + @property + def native_value(self) -> float: + return self._spec.getter(self._data_handler) + + async def async_set_native_value(self, value: float) -> None: + await self._spec.setter(self._data_handler, value) + self._coordinator.schedule_followup_refresh() diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 94708c5..4b5ed21 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -3,89 +3,97 @@ SensorDeviceClass ) -from . import AlsavoProDataCoordinator +from homeassistant.const import UnitOfTemperature +from homeassistant.helpers.entity import EntityCategory + +from . import AlsavoProDataCoordinator, AlsavoProEntity from .const import ( DOMAIN ) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) +# Shorthand so the long sensor list below stays readable. +DIAG = EntityCategory.DIAGNOSTIC + -async def async_setup_entry(hass, entry, async_add_devices): +async def async_setup_entry(hass, entry, async_add_entities): coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices( + async_add_entities( [ AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Water In", - "°C", + UnitOfTemperature.CELSIUS, 16, False, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Water Out", - "°C", + UnitOfTemperature.CELSIUS, 17, False, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Ambient", - "°C", + UnitOfTemperature.CELSIUS, 18, False, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Cold pipe", - "°C", + UnitOfTemperature.CELSIUS, 19, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "heating pipe", - "°C", + UnitOfTemperature.CELSIUS, 20, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "IPM module", - "°C", + UnitOfTemperature.CELSIUS, 21, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Exhaust temperature", - "°C", + UnitOfTemperature.CELSIUS, 23, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Heating mode target", - "°C", + UnitOfTemperature.CELSIUS, 1, True, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Cooling mode target", - "°C", + UnitOfTemperature.CELSIUS, 2, True, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Auto mode target", - "°C", + UnitOfTemperature.CELSIUS, 3, True, "mdi:thermometer"), @@ -116,91 +124,96 @@ async def async_setup_entry(hass, entry, async_add_devices): "", 34, False, - "mdi:bell-alert"), + "mdi:bell-alert", + category=DIAG), AlsavoProSensor(coordinator, None, "Alarm code 1", "", 48, False, - "mdi:bell-alert"), + "mdi:bell-alert", + category=DIAG), AlsavoProSensor(coordinator, None, "Alarm code 2", "", 49, False, - "mdi:bell-alert"), + "mdi:bell-alert", + category=DIAG), AlsavoProSensor(coordinator, None, "Alarm code 3", "", 50, False, - "mdi:bell-alert"), + "mdi:bell-alert", + category=DIAG), AlsavoProSensor(coordinator, None, "Alarm code 4", "", 51, False, - "mdi:bell-alert"), - AlsavoProSensor(coordinator, - None, - "Alarm code 4", - "", - 51, - False, - "mdi:bell-alert"), + "mdi:bell-alert", + category=DIAG), AlsavoProSensor(coordinator, None, "System status code", "", 52, False, - "mdi:state-machine"), + "mdi:state-machine", + category=DIAG), AlsavoProSensor(coordinator, None, "System running code", "", 53, False, - "mdi:state-machine"), + "mdi:state-machine", + category=DIAG), AlsavoProSensor(coordinator, None, "Device type", "", 64, False, - "mdi:heat-pump"), + "mdi:heat-pump", + category=DIAG), AlsavoProSensor(coordinator, None, "Main board HW revision", "", 65, False, - "mdi:heat-pump"), + "mdi:heat-pump", + category=DIAG), AlsavoProSensor(coordinator, None, "Main board SW revision", "", 66, False, - "mdi:heat-pump"), + "mdi:heat-pump", + category=DIAG), AlsavoProSensor(coordinator, None, "Manual HW code", "", 67, False, - "mdi:heat-pump"), + "mdi:heat-pump", + category=DIAG), AlsavoProSensor(coordinator, None, "Manual SW code", "", 68, False, - "mdi:heat-pump"), + "mdi:heat-pump", + category=DIAG), AlsavoProSensor(coordinator, None, "Power mode", @@ -208,29 +221,195 @@ async def async_setup_entry(hass, entry, async_add_devices): 16, True, "mdi:heat-pump"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Compressor input temperature", + UnitOfTemperature.CELSIUS, + 24, + False, + "mdi:thermometer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "EEV opening", + "", + 25, + False, + "mdi:valve", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Compressor speed setting", + "", + 33, + False, + "mdi:speedometer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Device status code", + "", + 54, + False, + "mdi:state-machine", + category=DIAG), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Heating max temperature", + UnitOfTemperature.CELSIUS, + 55, + False, + "mdi:thermometer-high", + category=DIAG), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Cooling min temperature", + UnitOfTemperature.CELSIUS, + 56, + False, + "mdi:thermometer-low", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Manual frequency setting", + "", + 6, + True, + "mdi:sine-wave", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Manual EEV setting", + "", + 7, + True, + "mdi:valve", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Manual fan speed setting", + "", + 8, + True, + "mdi:fan", + category=DIAG), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Defrost in temperature", + UnitOfTemperature.CELSIUS, + 9, + True, + "mdi:thermometer", + category=DIAG), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Defrost out temperature", + UnitOfTemperature.CELSIUS, + 10, + True, + "mdi:thermometer", + category=DIAG), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Water temperature calibration", + UnitOfTemperature.CELSIUS, + 11, + True, + "mdi:thermometer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Defrost in time", + "min", + 12, + True, + "mdi:timer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Defrost out time", + "min", + 13, + True, + "mdi:timer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Hot over", + "", + 14, + True, + "mdi:thermometer-high", + signed=True, + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Cold over", + "", + 15, + True, + "mdi:thermometer-low", + signed=True, + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Unknown config 17", + "", + 17, + True, + "mdi:help-circle", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Current time", + "", + 32, + True, + "mdi:clock", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Timer on time", + "", + 33, + True, + "mdi:timer", + category=DIAG), + AlsavoProSensor(coordinator, + None, + "Timer off time", + "", + 34, + True, + "mdi:timer", + category=DIAG), AlsavoProErrorSensor(coordinator, "Error messages"), ] ) -class AlsavoProSensor(CoordinatorEntity, SensorEntity): +class AlsavoProSensor(AlsavoProEntity, CoordinatorEntity, SensorEntity): def __init__(self, coordinator: AlsavoProDataCoordinator, device_class: SensorDeviceClass, name: str, unit: str, idx: int, from_config: bool, - icon: str): + icon: str, + signed: bool = False, + category: EntityCategory | None = None): super().__init__(coordinator) self.data_coordinator = coordinator self._data_handler = self.data_coordinator.data_handler self._name = name self._attr_device_class = device_class self._attr_native_unit_of_measurement = unit + self._attr_entity_category = category self._dataIdx = idx self._config = from_config self._icon = icon + self._signed = signed @property def name(self): @@ -251,24 +430,24 @@ def unique_id(self): @property def native_value(self): - # Hent data fra data_handler her if self._attr_device_class == SensorDeviceClass.TEMPERATURE: if self._config: return self._data_handler.get_temperature_from_config(self._dataIdx) - else: - return self._data_handler.get_temperature_from_status(self._dataIdx) - else: + return self._data_handler.get_temperature_from_status(self._dataIdx) + if self._signed: if self._config: - return self._data_handler.get_config_value(self._dataIdx) - else: - return self._data_handler.get_status_value(self._dataIdx) + return self._data_handler.get_signed_config_value(self._dataIdx) + return self._data_handler.get_signed_status_value(self._dataIdx) + if self._config: + return self._data_handler.get_config_value(self._dataIdx) + return self._data_handler.get_status_value(self._dataIdx) @property def icon(self): return self._icon -class AlsavoProErrorSensor(CoordinatorEntity, SensorEntity): +class AlsavoProErrorSensor(AlsavoProEntity, CoordinatorEntity, SensorEntity): def __init__(self, coordinator: AlsavoProDataCoordinator, name: str): super().__init__(coordinator) @@ -287,6 +466,10 @@ def unique_id(self): """Return a unique ID.""" return f"{self._data_handler.unique_id}_{self._name}" + @property + def available(self) -> bool: + return self._data_handler.is_online + @property def native_value(self): return self._data_handler.errors @@ -294,7 +477,3 @@ def native_value(self): @property def icon(self): return self._icon - - async def async_update(self): - """Get the latest data.""" - self._data_handler = self.data_coordinator.data_handler diff --git a/custom_components/alsavopro/switch.py b/custom_components/alsavopro/switch.py new file mode 100644 index 0000000..db781f4 --- /dev/null +++ b/custom_components/alsavopro/switch.py @@ -0,0 +1,85 @@ +"""Switch entities for Alsavo Pro boolean flags in config register 4.""" +from dataclasses import dataclass +from typing import Awaitable, Callable + +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator, AlsavoProEntity +from .AlsavoPyCtrl import AlsavoPro +from .const import DOMAIN + + +@dataclass(frozen=True, kw_only=True) +class AlsavoSwitchSpec: + key: str + name: str + icon: str + getter: Callable[[AlsavoPro], bool] + setter: Callable[[AlsavoPro, bool], Awaitable[None]] + + +SWITCH_SPECS: tuple[AlsavoSwitchSpec, ...] = ( + AlsavoSwitchSpec( + key="timer_on_enabled", + name="Timer on enabled", + icon="mdi:timer-play-outline", + getter=lambda dh: dh.is_timer_on_enabled, + setter=lambda dh, v: dh.set_timer_on_enabled(v), + ), + AlsavoSwitchSpec( + key="timer_off_enabled", + name="Timer off enabled", + icon="mdi:timer-stop-outline", + getter=lambda dh: dh.is_timer_off_enabled, + setter=lambda dh, v: dh.set_timer_off_enabled(v), + ), + AlsavoSwitchSpec( + key="pump_run_mode", + name="Pump continuous run", + icon="mdi:pump", + getter=lambda dh: dh.is_pump_run_mode_enabled, + setter=lambda dh, v: dh.set_pump_run_mode_enabled(v), + ), +) + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator: AlsavoProDataCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(AlsavoProSwitch(coordinator, spec) for spec in SWITCH_SPECS) + + +class AlsavoProSwitch(AlsavoProEntity, CoordinatorEntity, SwitchEntity): + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoSwitchSpec): + super().__init__(coordinator) + self._coordinator = coordinator + self._data_handler: AlsavoPro = coordinator.data_handler + self._spec = spec + self._attr_icon = spec.icon + + @property + def name(self) -> str: + return f"{DOMAIN}_{self._data_handler.name}_{self._spec.name}" + + @property + def unique_id(self) -> str: + return f"{self._data_handler.unique_id}_{self._spec.key}" + + @property + def available(self) -> bool: + return self._data_handler.is_online + + @property + def is_on(self) -> bool: + return self._spec.getter(self._data_handler) + + async def async_turn_on(self, **_kwargs) -> None: + await self._spec.setter(self._data_handler, True) + self._coordinator.schedule_followup_refresh() + + async def async_turn_off(self, **_kwargs) -> None: + await self._spec.setter(self._data_handler, False) + self._coordinator.schedule_followup_refresh() diff --git a/custom_components/alsavopro/time.py b/custom_components/alsavopro/time.py new file mode 100644 index 0000000..0c0530a --- /dev/null +++ b/custom_components/alsavopro/time.py @@ -0,0 +1,79 @@ +"""Time entities for Alsavo Pro timer-on / timer-off scheduling.""" +from dataclasses import dataclass +from datetime import time +from typing import Awaitable, Callable + +from homeassistant.components.time import TimeEntity +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator, AlsavoProEntity +from .AlsavoPyCtrl import AlsavoPro +from .const import DOMAIN + + +@dataclass(frozen=True, kw_only=True) +class AlsavoTimeSpec: + key: str + name: str + icon: str + getter: Callable[[AlsavoPro], tuple[int, int]] + setter: Callable[[AlsavoPro, int, int], Awaitable[None]] + + +# Register 33/34 store the time as (hour << 8) | minute. +TIME_SPECS: tuple[AlsavoTimeSpec, ...] = ( + AlsavoTimeSpec( + key="timer_on_time", + name="Timer on time", + icon="mdi:clock-start", + getter=lambda dh: dh.timer_on_hhmm, + setter=lambda dh, h, m: dh.set_timer_on_hhmm(h, m), + ), + AlsavoTimeSpec( + key="timer_off_time", + name="Timer off time", + icon="mdi:clock-end", + getter=lambda dh: dh.timer_off_hhmm, + setter=lambda dh, h, m: dh.set_timer_off_hhmm(h, m), + ), +) + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator: AlsavoProDataCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(AlsavoProTime(coordinator, spec) for spec in TIME_SPECS) + + +class AlsavoProTime(AlsavoProEntity, CoordinatorEntity, TimeEntity): + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoTimeSpec): + super().__init__(coordinator) + self._coordinator = coordinator + self._data_handler: AlsavoPro = coordinator.data_handler + self._spec = spec + self._attr_icon = spec.icon + + @property + def name(self) -> str: + return f"{DOMAIN}_{self._data_handler.name}_{self._spec.name}" + + @property + def unique_id(self) -> str: + return f"{self._data_handler.unique_id}_{self._spec.key}" + + @property + def available(self) -> bool: + return self._data_handler.is_online + + @property + def native_value(self) -> time | None: + hour, minute = self._spec.getter(self._data_handler) + if hour > 23 or minute > 59: + return None + return time(hour=hour, minute=minute) + + async def async_set_value(self, value: time) -> None: + await self._spec.setter(self._data_handler, value.hour, value.minute) + self._coordinator.schedule_followup_refresh() diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 8316f53..fc01461 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -1,23 +1,13 @@ import asyncio +import logging +_LOGGER = logging.getLogger(__name__) class UDPClient: """ Async UDP client """ def __init__(self, server_host, server_port): self.server_host = server_host self.server_port = server_port - self.loop = asyncio.get_event_loop() - - class SimpleClientProtocol(asyncio.DatagramProtocol): - # Sending only - def __init__(self, message): - self.message = message - self.transport = None - - def connection_made(self, transport): - self.transport = transport - self.transport.sendto(self.message) - self.transport.close() class EchoClientProtocol(asyncio.DatagramProtocol): # Send and receive @@ -42,8 +32,9 @@ def connection_lost(self, exc): self.future.set_exception(ConnectionError("Connection lost")) async def send_rcv(self, bytes_to_send): - future = self.loop.create_future() - transport, protocol = await self.loop.create_datagram_endpoint( + loop = asyncio.get_running_loop() + future = loop.create_future() + transport, protocol = await loop.create_datagram_endpoint( lambda: self.EchoClientProtocol(bytes_to_send, future), remote_addr=(self.server_host, self.server_port) ) @@ -52,14 +43,7 @@ async def send_rcv(self, bytes_to_send): data = await asyncio.wait_for(future, timeout=5.0) return data, b'0' except asyncio.TimeoutError: - _LOGGER.error("Timeout: No response from server in 5 seconds.") + _LOGGER.debug("Timeout: No response from server in 5 seconds.") return None finally: transport.close() - - async def send(self, bytes_to_send): - transport, protocol = await self.loop.create_datagram_endpoint( - lambda: self.SimpleClientProtocol(bytes_to_send), - remote_addr=(self.server_host, self.server_port) - ) - transport.close()