From 346966b9581cb223e47fc8f607b93937626ee766 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:58:02 +0200 Subject: [PATCH 01/41] Update sensor.py double entry error code 4 --- custom_components/alsavopro/sensor.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 94708c5..2fde71b 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -145,13 +145,6 @@ async def async_setup_entry(hass, entry, async_add_devices): 51, False, "mdi:bell-alert"), - AlsavoProSensor(coordinator, - None, - "Alarm code 4", - "", - 51, - False, - "mdi:bell-alert"), AlsavoProSensor(coordinator, None, "System status code", From e5ac6744ecbdcd7040e2368def6e2ccb2b389e6e Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:40:31 +0200 Subject: [PATCH 02/41] Update AlsavoPyCtrl.py Error Logger undefined + code cleanup error is: Logger: custom_components.alsavopro.AlsavoPyCtrl Bron: custom_components/alsavopro/AlsavoPyCtrl.py:44 integratie: AlsavoPro (documentatie) Eerst voorgekomen: 13:04:41 (8 gebeurtenissen) Laatst gelogd: 13:59:26 Unable to update: name '_LOGGER' is not defined Unable to update: unpack requires a buffer of 164 bytes --- custom_components/alsavopro/AlsavoPyCtrl.py | 37 +++++++++------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index b2e4625..8207617 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -4,8 +4,7 @@ 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 custom_components.alsavopro.const import MODE_TO_CONFIG, NO_WATER_FLUX, WATER_TEMP_TOO_LOW, MAX_UPDATE_RETRIES, MAX_SET_CONFIG_RETRIES from .udpclient import UDPClient _LOGGER = logging.getLogger(__name__) @@ -28,21 +27,19 @@ def __init__(self, name, serial_no, ip_address, port_no, password): self._online = False async def update(self): - _LOGGER.debug(f"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 + _LOGGER.debug("update") + for attempt in range(MAX_UPDATE_RETRIES): + 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 + self._online = True + return + except Exception as e: + _LOGGER.warning(f"Update attempt {attempt + 1} failed: {e}") + _LOGGER.error("Unable to update after max retries") + self._online = False async def set_config(self, idx: int, value: int): _LOGGER.debug(f"set_config({idx}, {value})") @@ -228,8 +225,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 @@ -466,8 +462,7 @@ 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(f"Received handshake, CSID={hex(self.CSID)}, DSID={hex(self.DSIS)}, "f"server token {hex(self.serverToken)}") ctx = hashlib.md5() ctx.update(self.clientToken.to_bytes(4, "big")) From bcc4a24b7e48d34ac574fa71b7cac9d7af34322a Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:53:51 +0200 Subject: [PATCH 03/41] Update climate.py temperature step by 1.0 degrees removed AUTO mode --- custom_components/alsavopro/climate.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 858e143..785c9e5 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -1,4 +1,3 @@ -"""Support for Alsavo Pro wifi-enabled pool heaters.""" import logging from homeassistant.components.climate import ( @@ -14,7 +13,6 @@ CONF_IP_ADDRESS, CONF_PORT, CONF_NAME, - PRECISION_TENTHS, UnitOfTemperature, ) @@ -72,8 +70,7 @@ def hvac_mode(self): """Return hvac operation i.e. heat, cool mode.""" operating_mode_map = { 0: HVACMode.COOL, - 1: HVACMode.HEAT, - 2: HVACMode.AUTO + 1: HVACMode.HEAT } if not self._data_handler.is_power_on: @@ -91,8 +88,7 @@ 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.COOL: "mdi:snowflake" } return hvac_mode_icons.get(self.hvac_mode, "mdi:hvac-off") @@ -100,7 +96,7 @@ def icon(self): @property def hvac_modes(self): """Return the list of available hvac operation modes.""" - return [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF] + return [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] @property def preset_modes(self): @@ -112,8 +108,7 @@ async def async_set_hvac_mode(self, 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.HEAT: self._data_handler.set_heating_mode } action = hvac_mode_actions.get(hvac_mode) @@ -124,9 +119,9 @@ async def async_set_hvac_mode(self, hvac_mode): 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 + 'Silent': 0, + 'Smart': 1, + 'Powerful': 2 } power_mode = preset_mode_to_power_mode.get(preset_mode) @@ -162,7 +157,7 @@ def target_temperature(self): @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return PRECISION_TENTHS + return 1.0 async def async_set_temperature(self, **kwargs): """Set new target temperature.""" From a376ec08b7050b8af65f7784db7b35e5ddc4b0b8 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:03:26 +0200 Subject: [PATCH 04/41] Update udpclient.py Added logger to fix error "WARNING (MainThread) [custom_components.alsavopro.AlsavoPyCtrl] Update attempt 1 failed: name '_LOGGER' is not defined" --- custom_components/alsavopro/udpclient.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 8316f53..6967090 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -1,5 +1,7 @@ import asyncio +import logging +_LOGGER = logging.getLogger(__name__) class UDPClient: """ Async UDP client """ From 23c9bf572049e4ce58e16711df23d874cbde2b3b Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:05:57 +0200 Subject: [PATCH 05/41] Update manifest.json update version --- custom_components/alsavopro/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index df4a841..be45607 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "0.0.1" + "version": "1.0.0" } From 4642de0fdc6c76f71e1b78a50c8c76fba1e6996f Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:25:23 +0100 Subject: [PATCH 06/41] 1.0.1 - Fixed `NoneType object is not subscriptable` crash when pump is temporarily offline during auth challenge - Fixed `unpack requires a buffer of X bytes` error when receiving truncated UDP packets - Added 2-second delay between update retries so the pump has time to recover when briefly offline --- README.md | 7 +++++++ custom_components/alsavopro/AlsavoPyCtrl.py | 7 +++++++ custom_components/alsavopro/manifest.json | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd3188f..5e126a8 100644 --- a/README.md +++ b/README.md @@ -25,5 +25,12 @@ 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. +## Changelog + +### 1.0.1 +- Fixed `NoneType object is not subscriptable` crash when pump is temporarily offline during auth challenge +- Fixed `unpack requires a buffer of X bytes` error when receiving truncated UDP packets +- Added 2-second delay between update retries so the pump has time to recover when briefly offline + ## 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 8207617..fb19f27 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import logging import random @@ -38,6 +39,8 @@ async def update(self): return except Exception as e: _LOGGER.warning(f"Update attempt {attempt + 1} failed: {e}") + if attempt + 1 < MAX_UPDATE_RETRIES: + await asyncio.sleep(2) _LOGGER.error("Unable to update after max retries") self._online = False @@ -296,6 +299,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 @@ -407,6 +412,8 @@ async def send(self, 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): diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index be45607..09131c5 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": "1.0.0" + "version": "1.0.1" } From ba12745b6a955a3e424c7c696f8081f205450893 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:45:40 +0100 Subject: [PATCH 07/41] 1.0.2 - Fixed `set_config` recursive retry replaced with iterative loop to prevent stack overflow and stale `_online` state - Fixed `is_online` now correctly reflects live connection state instead of stale data presence - Fixed `Payload.get_value` off-by-one bounds check --- README.md | 7 +++++- custom_components/alsavopro/AlsavoPyCtrl.py | 26 ++++++++++----------- custom_components/alsavopro/manifest.json | 2 +- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5e126a8..dfce7fd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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. @@ -27,6 +27,11 @@ Ip-address and port can be one of two: ## Changelog +### 1.0.2 +- Fixed `set_config` recursive retry replaced with iterative loop to prevent stack overflow and stale `_online` state +- Fixed `is_online` now correctly reflects live connection state instead of stale data presence +- Fixed `Payload.get_value` off-by-one bounds check + ### 1.0.1 - Fixed `NoneType object is not subscriptable` crash when pump is temporarily offline during auth challenge - Fixed `unpack requires a buffer of X bytes` error when receiving truncated UDP packets diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index fb19f27..07c7a96 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -46,22 +46,22 @@ async def update(self): async def set_config(self, idx: int, value: int): _LOGGER.debug(f"set_config({idx}, {value})") - 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) + for attempt in range(MAX_SET_CONFIG_RETRIES): + 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) self._online = True - else: - self._set_retries = 0 - _LOGGER.error(f"Unable to set config: {idx}, {value} Error: {e}") - self._online = False + return + except Exception as e: + _LOGGER.warning(f"Set config attempt {attempt + 1} failed: {e}") + if attempt + 1 < MAX_SET_CONFIG_RETRIES: + await asyncio.sleep(2) + _LOGGER.error(f"Unable to set config: {idx}, {value} after max retries") + self._online = False @property def is_online(self) -> bool: - return self._data.parts > 0 + return self._online @property def unique_id(self): @@ -290,7 +290,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 >= self.data.__len__(): return 0 return self.data[idx - self.startIdx] diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 09131c5..23e7ea3 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.1" + "version": "1.0.2" } From 47272886ee835a514c2f3c9df3961bf09acd8347 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:48:23 +0100 Subject: [PATCH 08/41] update readme --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index dfce7fd..e51cdfc 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,6 @@ 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! - -If some adult with the proper knowledge could improve this, and maybe make it installable with HACS, please feel free to do so! - ## Install #### Manually In Home Assistant, create a folder under *custom_components* named *AlsavoPro* and copy all the content of this project to that folder. From 3f83808f9b40f868786f5dd0f994f0444db6df98 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:03:58 +0100 Subject: [PATCH 09/41] alarm codes --- README.md | 55 +++++++++++++++++++++ custom_components/alsavopro/AlsavoPyCtrl.py | 15 +++--- custom_components/alsavopro/const.py | 50 +++++++++++++++++-- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e51cdfc..d918dfc 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,63 @@ 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. +## 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) | + ## Changelog +### 1.0.3 +- Full alarm code decoding for all EE (EE01–EE28) and PP (PP01–PP11) fault codes across registers 48–50 + ### 1.0.2 - Fixed `set_config` recursive retry replaced with iterative loop to prevent stack overflow and stale `_online` state - Fixed `is_online` now correctly reflects live connection state instead of stale data presence diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 07c7a96..a5ceead 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -5,7 +5,7 @@ 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 custom_components.alsavopro.const import MODE_TO_CONFIG, ALARM_REGISTER_48, ALARM_REGISTER_49, ALARM_REGISTER_50, MAX_UPDATE_RETRIES, MAX_SET_CONFIG_RETRIES from .udpclient import UDPClient _LOGGER = logging.getLogger(__name__) @@ -138,12 +138,13 @@ def manual_defrost(self): @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 + 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) async def set_power_off(self): await self.set_config(4, self._data.get_config_value(4) & 0xFFDF) diff --git a/custom_components/alsavopro/const.py b/custom_components/alsavopro/const.py index d89953e..8bf4514 100755 --- a/custom_components/alsavopro/const.py +++ b/custom_components/alsavopro/const.py @@ -13,9 +13,53 @@ 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)", +} # Max retries MAX_UPDATE_RETRIES = 10 From 070bfcb92ace699ab4d25ebd40e284b4d7eac9d6 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:54:13 +0100 Subject: [PATCH 10/41] 1.0.4 Added Auto HVAC mode (maps to pump's internal auto mode) - Added 18 new sensors: compressor input temp, EEV opening, compressor speed, device status code, heating max/cooling min temps, manual settings, defrost config, timer config, and more --- README.md | 82 +++++++++++++++ custom_components/alsavopro/climate.py | 11 +- custom_components/alsavopro/sensor.py | 140 +++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d918dfc..afd2714 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,90 @@ The integration exposes four alarm code sensors (`alarm_code_1` through `alarm_c | 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 | + +Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. + +## 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 threshold | +| Cold over | Low temperature threshold | +| 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 | + ## Changelog +### 1.0.4 +- Added Auto HVAC mode (maps to pump's internal auto mode) +- Added 18 new sensors: compressor input temp, EEV opening, compressor speed, device status code, heating max/cooling min temps, manual settings, defrost config, timer config, and more + ### 1.0.3 - Full alarm code decoding for all EE (EE01–EE28) and PP (PP01–PP11) fault codes across registers 48–50 diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 785c9e5..35bbf4c 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -70,7 +70,8 @@ def hvac_mode(self): """Return hvac operation i.e. heat, cool mode.""" operating_mode_map = { 0: HVACMode.COOL, - 1: HVACMode.HEAT + 1: HVACMode.HEAT, + 2: HVACMode.AUTO, } if not self._data_handler.is_power_on: @@ -88,7 +89,8 @@ def icon(self): """Return nice icon for heater.""" hvac_mode_icons = { HVACMode.HEAT: "mdi:fire", - HVACMode.COOL: "mdi:snowflake" + HVACMode.COOL: "mdi:snowflake", + HVACMode.AUTO: "mdi:autorenew", } return hvac_mode_icons.get(self.hvac_mode, "mdi:hvac-off") @@ -96,7 +98,7 @@ def icon(self): @property def hvac_modes(self): """Return the list of available hvac operation modes.""" - return [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + return [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF] @property def preset_modes(self): @@ -108,7 +110,8 @@ async def async_set_hvac_mode(self, 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.HEAT: self._data_handler.set_heating_mode, + HVACMode.AUTO: self._data_handler.set_auto_mode, } action = hvac_mode_actions.get(hvac_mode) diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 2fde71b..e35f12a 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -201,6 +201,146 @@ async def async_setup_entry(hass, entry, async_add_devices): 16, True, "mdi:heat-pump"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Compressor input temperature", + "°C", + 24, + False, + "mdi:thermometer"), + AlsavoProSensor(coordinator, + None, + "EEV opening", + "", + 25, + False, + "mdi:valve"), + AlsavoProSensor(coordinator, + None, + "Compressor speed setting", + "", + 33, + False, + "mdi:speedometer"), + AlsavoProSensor(coordinator, + None, + "Device status code", + "", + 54, + False, + "mdi:state-machine"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Heating max temperature", + "°C", + 55, + False, + "mdi:thermometer-high"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Cooling min temperature", + "°C", + 56, + False, + "mdi:thermometer-low"), + AlsavoProSensor(coordinator, + None, + "Manual frequency setting", + "", + 6, + True, + "mdi:sine-wave"), + AlsavoProSensor(coordinator, + None, + "Manual EEV setting", + "", + 7, + True, + "mdi:valve"), + AlsavoProSensor(coordinator, + None, + "Manual fan speed setting", + "", + 8, + True, + "mdi:fan"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Defrost in temperature", + "°C", + 9, + True, + "mdi:thermometer"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Defrost out temperature", + "°C", + 10, + True, + "mdi:thermometer"), + AlsavoProSensor(coordinator, + SensorDeviceClass.TEMPERATURE, + "Water temperature calibration", + "°C", + 11, + True, + "mdi:thermometer"), + AlsavoProSensor(coordinator, + None, + "Defrost in time", + "min", + 12, + True, + "mdi:timer"), + AlsavoProSensor(coordinator, + None, + "Defrost out time", + "min", + 13, + True, + "mdi:timer"), + AlsavoProSensor(coordinator, + None, + "Hot over", + "", + 14, + True, + "mdi:thermometer-high"), + AlsavoProSensor(coordinator, + None, + "Cold over", + "", + 15, + True, + "mdi:thermometer-low"), + AlsavoProSensor(coordinator, + None, + "Unknown config 17", + "", + 17, + True, + "mdi:help-circle"), + AlsavoProSensor(coordinator, + None, + "Current time", + "", + 32, + True, + "mdi:clock"), + AlsavoProSensor(coordinator, + None, + "Timer on time", + "", + 33, + True, + "mdi:timer"), + AlsavoProSensor(coordinator, + None, + "Timer off time", + "", + 34, + True, + "mdi:timer"), AlsavoProErrorSensor(coordinator, "Error messages"), ] From 4dcd40b1c3cf933850bff328a2cbf1a64d6556de Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:56:59 +0100 Subject: [PATCH 11/41] small update --- README.md | 4 ++++ custom_components/alsavopro/climate.py | 12 +++++++----- custom_components/alsavopro/sensor.py | 6 ++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index afd2714..9fab8d0 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,10 @@ Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. ### 1.0.4 - Added Auto HVAC mode (maps to pump's internal auto mode) - Added 18 new sensors: compressor input temp, EEV opening, compressor speed, device status code, heating max/cooling min temps, manual settings, defrost config, timer config, and more +- Fixed `ClimateEntityFeature.TURN_ON`/`TURN_OFF` missing from supported features (required in HA 2024.2+) +- Fixed `hvac_mode` returning `None` for unknown operating modes, now falls back to `HVACMode.OFF` +- Fixed `AlsavoProErrorSensor` missing `available` property, entity now correctly reflects online/offline state +- Removed unused imports in `climate.py` and `sensor.py` ### 1.0.3 - Full alarm code decoding for all EE (EE01–EE28) and PP (PP01–PP11) fault codes across registers 48–50 diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 35bbf4c..370dd7f 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -1,7 +1,6 @@ import logging from homeassistant.components.climate import ( - PLATFORM_SCHEMA, ClimateEntity, ClimateEntityFeature, HVACMode @@ -18,8 +17,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) from . import AlsavoProDataCoordinator @@ -48,7 +45,12 @@ def __init__(self, coordinator: AlsavoProDataCoordinator): @property def supported_features(self): """Return the list of supported features.""" - return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + return ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) @property def unique_id(self): @@ -77,7 +79,7 @@ def hvac_mode(self): if not self._data_handler.is_power_on: return HVACMode.OFF - return operating_mode_map.get(self._data_handler.operating_mode) + return operating_mode_map.get(self._data_handler.operating_mode, HVACMode.OFF) @property def preset_mode(self): diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index e35f12a..18801b6 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -10,8 +10,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) @@ -420,6 +418,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 From 85153b9cc09784946a94f0c47ede4f5da25af6c8 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:08:56 +0100 Subject: [PATCH 12/41] update readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9fab8d0..57291cc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ 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. +## 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 + ## 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. From 90401737987c98e304a2f32d37d11d4d49337e00 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:09:17 +0200 Subject: [PATCH 13/41] updates, code cleanup --- custom_components/alsavopro/AlsavoPyCtrl.py | 8 ++--- custom_components/alsavopro/__init__.py | 14 +++----- custom_components/alsavopro/climate.py | 4 --- custom_components/alsavopro/config_flow.py | 22 +++++------- custom_components/alsavopro/manifest.json | 2 +- custom_components/alsavopro/sensor.py | 38 +++++++++++---------- custom_components/alsavopro/udpclient.py | 9 ++--- 7 files changed, 44 insertions(+), 53 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index a5ceead..d2aaa18 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -5,7 +5,7 @@ import struct from datetime import datetime, timezone from enum import Enum -from custom_components.alsavopro.const import MODE_TO_CONFIG, ALARM_REGISTER_48, ALARM_REGISTER_49, ALARM_REGISTER_50, MAX_UPDATE_RETRIES, MAX_SET_CONFIG_RETRIES +from .const import MODE_TO_CONFIG, ALARM_REGISTER_48, ALARM_REGISTER_49, ALARM_REGISTER_50, MAX_UPDATE_RETRIES, MAX_SET_CONFIG_RETRIES from .udpclient import UDPClient _LOGGER = logging.getLogger(__name__) @@ -401,13 +401,13 @@ def __init__(self): 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())") + _LOGGER.debug("send()") await self.client.send(bytes_to_send) async def get_auth_challenge(self): diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 7647508..de30f58 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -1,8 +1,8 @@ """Alsavo Pro pool heat pump integration.""" +import asyncio import logging from datetime import timedelta -import async_timeout from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) @@ -50,13 +50,9 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - unload_ok = await hass.config_entries.async_forward_entry_unload( - config_entry, "climate" + return await hass.config_entries.async_forward_entry_unloads( + config_entry, ["climate", "sensor"] ) - unload_ok |= await hass.config_entries.async_forward_entry_unload( - config_entry, "sensor" - ) - return unload_ok class AlsavoProDataCoordinator(DataUpdateCoordinator): @@ -75,8 +71,8 @@ def __init__(self, hass, data_handler): async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self.data_handler.update() return self.data_handler - except Exception as ex: + except Exception: _LOGGER.debug("_async_update_data timed out") diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 370dd7f..6979ef5 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -8,10 +8,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_IP_ADDRESS, - CONF_PORT, - CONF_NAME, UnitOfTemperature, ) diff --git a/custom_components/alsavopro/config_flow.py b/custom_components/alsavopro/config_flow.py index 60e47b1..0442436 100755 --- a/custom_components/alsavopro/config_flow.py +++ b/custom_components/alsavopro/config_flow.py @@ -14,8 +14,6 @@ DOMAIN ) -# _LOGGER = logging.getLogger(__name__) - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): str, @@ -30,7 +28,6 @@ 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: @@ -45,14 +42,11 @@ async def validate_input(hass: core.HomeAssistant, name, serial_no, ip_address, ]): 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.""" @@ -85,6 +79,8 @@ async def async_step_user(self, user_input=None): errors["base"] = "connection_error" except MissingNameValue: errors["base"] = "missing_name" + except MissingPasswordValue: + errors["base"] = "missing_password" return self.async_show_form( step_id="user", @@ -92,6 +88,11 @@ async def async_step_user(self, user_input=None): errors=errors, ) + @staticmethod + @callback + def async_get_options_flow(config_entry): + return OptionsFlowHandler() + class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input=None): @@ -103,11 +104,6 @@ async def async_step_init(self, user_input=None): ) -@callback -def async_get_options_flow(config_entry): - return OptionsFlowHandler(config_entry) - - class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" @@ -121,4 +117,4 @@ class MissingNameValue(exceptions.HomeAssistantError): class MissingPasswordValue(exceptions.HomeAssistantError): - """Error to indicate name is missing.""" + """Error to indicate password is missing.""" diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 23e7ea3..058b830 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.2" + "version": "1.0.4" } diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 18801b6..36d7341 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -3,6 +3,8 @@ SensorDeviceClass ) +from homeassistant.const import UnitOfTemperature + from . import AlsavoProDataCoordinator from .const import ( DOMAIN @@ -13,77 +15,77 @@ ) -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"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "heating pipe", - "°C", + UnitOfTemperature.CELSIUS, 20, False, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "IPM module", - "°C", + UnitOfTemperature.CELSIUS, 21, False, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Exhaust temperature", - "°C", + UnitOfTemperature.CELSIUS, 23, False, "mdi:thermometer"), 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"), @@ -202,7 +204,7 @@ async def async_setup_entry(hass, entry, async_add_devices): AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Compressor input temperature", - "°C", + UnitOfTemperature.CELSIUS, 24, False, "mdi:thermometer"), @@ -230,14 +232,14 @@ async def async_setup_entry(hass, entry, async_add_devices): AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Heating max temperature", - "°C", + UnitOfTemperature.CELSIUS, 55, False, "mdi:thermometer-high"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Cooling min temperature", - "°C", + UnitOfTemperature.CELSIUS, 56, False, "mdi:thermometer-low"), @@ -265,21 +267,21 @@ async def async_setup_entry(hass, entry, async_add_devices): AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Defrost in temperature", - "°C", + UnitOfTemperature.CELSIUS, 9, True, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Defrost out temperature", - "°C", + UnitOfTemperature.CELSIUS, 10, True, "mdi:thermometer"), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Water temperature calibration", - "°C", + UnitOfTemperature.CELSIUS, 11, True, "mdi:thermometer"), diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 6967090..26a7c35 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -8,7 +8,6 @@ class UDPClient: 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 @@ -44,8 +43,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) ) @@ -60,7 +60,8 @@ async def send_rcv(self, bytes_to_send): transport.close() async def send(self, bytes_to_send): - transport, protocol = await self.loop.create_datagram_endpoint( + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( lambda: self.SimpleClientProtocol(bytes_to_send), remote_addr=(self.server_host, self.server_port) ) From 2406db4338fdbcd088b0d0438a1edfd7b2e78fe8 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:10:52 +0200 Subject: [PATCH 14/41] changelog --- CHANGELOG.md | 38 +++++++++++++++++++++++ custom_components/alsavopro/manifest.json | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b96394 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## [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/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 058b830..708461d 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.4" + "version": "1.0.5" } From 45567a30e6b512d91f90d23887c79e7485edbd75 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:19:13 +0200 Subject: [PATCH 15/41] fix auth failure and timeout --- CHANGELOG.md | 7 ++ custom_components/alsavopro/AlsavoPyCtrl.py | 10 +++ custom_components/alsavopro/__init__.py | 6 +- custom_components/alsavopro/manifest.json | 2 +- custom_components/alsavopro/udpclient.py | 77 ++++++++++----------- 5 files changed, 56 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b96394..2436dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.0.6] - 2026-05-13 + +### Fixed +- Fixed authentication failure ("Server not responding to auth response") caused by UDP socket being recreated for each packet, resulting in a changing source port. The pump replied to the original port which was already closed. `UDPClient` now opens one socket per session and reuses it for all exchanges +- Increased UDP response timeout from 5 s to 10 s +- Increased coordinator overall timeout from 10 s to 45 s, allowing up to ~3 real retry attempts instead of the previous ~1 + ## [1.0.5] - 2026-05-13 ### Fixed diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index d2aaa18..baeab32 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -41,6 +41,8 @@ async def update(self): _LOGGER.warning(f"Update attempt {attempt + 1} failed: {e}") if attempt + 1 < MAX_UPDATE_RETRIES: await asyncio.sleep(2) + finally: + self._session.close() _LOGGER.error("Unable to update after max retries") self._online = False @@ -56,6 +58,8 @@ async def set_config(self, idx: int, value: int): _LOGGER.warning(f"Set config attempt {attempt + 1} failed: {e}") if attempt + 1 < MAX_SET_CONFIG_RETRIES: await asyncio.sleep(2) + finally: + self._session.close() _LOGGER.error(f"Unable to set config: {idx}, {value} after max retries") self._online = False @@ -452,6 +456,11 @@ async def set_config(self, idx: int, value: int): 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) + def close(self): + if self.client is not None: + self.client.close() + self.client = None + async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug("Connecting to Alsavo Pro") @@ -459,6 +468,7 @@ async def connect(self, server_ip, server_port, serial, password): self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) + await self.client.open() _LOGGER.debug("Asking for auth challenge") auth_challenge = await self.get_auth_challenge() diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index de30f58..7cbfbf1 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -71,8 +71,8 @@ def __init__(self, hass, data_handler): async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: - async with asyncio.timeout(10): + async with asyncio.timeout(45): await self.data_handler.update() return self.data_handler - except Exception: - _LOGGER.debug("_async_update_data timed out") + except Exception as ex: + _LOGGER.debug("_async_update_data failed: %s", ex) diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 708461d..c43614c 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.5" + "version": "1.0.6" } diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 26a7c35..ae9baf9 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -3,66 +3,59 @@ _LOGGER = logging.getLogger(__name__) + class UDPClient: - """ Async UDP client """ + """Async UDP client that reuses one socket for the full session.""" + def __init__(self, server_host, server_port): self.server_host = server_host self.server_port = server_port + self._transport = None + self._protocol = None - 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 - def __init__(self, message, future): - self.message = message - self.future = future - self.transport = None - - def connection_made(self, transport): - self.transport = transport - self.transport.sendto(self.message) + class _Protocol(asyncio.DatagramProtocol): + def __init__(self): + self._pending: asyncio.Future | None = None def datagram_received(self, data, addr): - self.future.set_result(data) - self.transport.close() + if self._pending is not None and not self._pending.done(): + self._pending.set_result(data) def error_received(self, exc): - self.future.set_exception(exc) + if self._pending is not None and not self._pending.done(): + self._pending.set_exception(exc) def connection_lost(self, exc): - if not self.future.done(): - self.future.set_exception(ConnectionError("Connection lost")) + if self._pending is not None and not self._pending.done(): + self._pending.set_exception(ConnectionError("Connection lost")) - async def send_rcv(self, bytes_to_send): + async def open(self): + """Open the UDP socket (call once per session).""" 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) + self._protocol = self._Protocol() + self._transport, _ = await loop.create_datagram_endpoint( + lambda: self._protocol, + remote_addr=(self.server_host, self.server_port), ) + def close(self): + """Close the UDP socket.""" + if self._transport is not None: + self._transport.close() + self._transport = None + + async def send_rcv(self, bytes_to_send): + """Send bytes and wait for a response on the same socket.""" + loop = asyncio.get_running_loop() + self._protocol._pending = loop.create_future() + self._transport.sendto(bytes_to_send) try: - data = await asyncio.wait_for(future, timeout=5.0) + data = await asyncio.wait_for(self._protocol._pending, timeout=10.0) return data, b'0' except asyncio.TimeoutError: - _LOGGER.error("Timeout: No response from server in 5 seconds.") + _LOGGER.error("Timeout: No response from server in 10 seconds.") return None - finally: - transport.close() async def send(self, bytes_to_send): - loop = asyncio.get_running_loop() - transport, protocol = await loop.create_datagram_endpoint( - lambda: self.SimpleClientProtocol(bytes_to_send), - remote_addr=(self.server_host, self.server_port) - ) - transport.close() + """Send bytes without waiting for a response.""" + self._transport.sendto(bytes_to_send) From bd9e35f61b8501618e02fcf4268af8eb842de0ad Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:31:22 +0200 Subject: [PATCH 16/41] Revert "fix auth failure and timeout" This reverts commit 45567a30e6b512d91f90d23887c79e7485edbd75. --- CHANGELOG.md | 7 -- custom_components/alsavopro/AlsavoPyCtrl.py | 10 --- custom_components/alsavopro/__init__.py | 6 +- custom_components/alsavopro/manifest.json | 2 +- custom_components/alsavopro/udpclient.py | 77 +++++++++++---------- 5 files changed, 46 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2436dc8..7b96394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## [1.0.6] - 2026-05-13 - -### Fixed -- Fixed authentication failure ("Server not responding to auth response") caused by UDP socket being recreated for each packet, resulting in a changing source port. The pump replied to the original port which was already closed. `UDPClient` now opens one socket per session and reuses it for all exchanges -- Increased UDP response timeout from 5 s to 10 s -- Increased coordinator overall timeout from 10 s to 45 s, allowing up to ~3 real retry attempts instead of the previous ~1 - ## [1.0.5] - 2026-05-13 ### Fixed diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index baeab32..d2aaa18 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -41,8 +41,6 @@ async def update(self): _LOGGER.warning(f"Update attempt {attempt + 1} failed: {e}") if attempt + 1 < MAX_UPDATE_RETRIES: await asyncio.sleep(2) - finally: - self._session.close() _LOGGER.error("Unable to update after max retries") self._online = False @@ -58,8 +56,6 @@ async def set_config(self, idx: int, value: int): _LOGGER.warning(f"Set config attempt {attempt + 1} failed: {e}") if attempt + 1 < MAX_SET_CONFIG_RETRIES: await asyncio.sleep(2) - finally: - self._session.close() _LOGGER.error(f"Unable to set config: {idx}, {value} after max retries") self._online = False @@ -456,11 +452,6 @@ async def set_config(self, idx: int, value: int): 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) - def close(self): - if self.client is not None: - self.client.close() - self.client = None - async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug("Connecting to Alsavo Pro") @@ -468,7 +459,6 @@ async def connect(self, server_ip, server_port, serial, password): self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) - await self.client.open() _LOGGER.debug("Asking for auth challenge") auth_challenge = await self.get_auth_challenge() diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 7cbfbf1..de30f58 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -71,8 +71,8 @@ def __init__(self, hass, data_handler): async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: - async with asyncio.timeout(45): + async with asyncio.timeout(10): await self.data_handler.update() return self.data_handler - except Exception as ex: - _LOGGER.debug("_async_update_data failed: %s", ex) + except Exception: + _LOGGER.debug("_async_update_data timed out") diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index c43614c..708461d 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.6" + "version": "1.0.5" } diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index ae9baf9..26a7c35 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -3,59 +3,66 @@ _LOGGER = logging.getLogger(__name__) - class UDPClient: - """Async UDP client that reuses one socket for the full session.""" - + """ Async UDP client """ def __init__(self, server_host, server_port): self.server_host = server_host self.server_port = server_port - self._transport = None - self._protocol = None - class _Protocol(asyncio.DatagramProtocol): - def __init__(self): - self._pending: asyncio.Future | None = None + 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 + def __init__(self, message, future): + self.message = message + self.future = future + self.transport = None + + def connection_made(self, transport): + self.transport = transport + self.transport.sendto(self.message) def datagram_received(self, data, addr): - if self._pending is not None and not self._pending.done(): - self._pending.set_result(data) + self.future.set_result(data) + self.transport.close() def error_received(self, exc): - if self._pending is not None and not self._pending.done(): - self._pending.set_exception(exc) + self.future.set_exception(exc) def connection_lost(self, exc): - if self._pending is not None and not self._pending.done(): - self._pending.set_exception(ConnectionError("Connection lost")) + if not self.future.done(): + self.future.set_exception(ConnectionError("Connection lost")) - async def open(self): - """Open the UDP socket (call once per session).""" + async def send_rcv(self, bytes_to_send): loop = asyncio.get_running_loop() - self._protocol = self._Protocol() - self._transport, _ = await loop.create_datagram_endpoint( - lambda: self._protocol, - remote_addr=(self.server_host, self.server_port), + 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) ) - def close(self): - """Close the UDP socket.""" - if self._transport is not None: - self._transport.close() - self._transport = None - - async def send_rcv(self, bytes_to_send): - """Send bytes and wait for a response on the same socket.""" - loop = asyncio.get_running_loop() - self._protocol._pending = loop.create_future() - self._transport.sendto(bytes_to_send) try: - data = await asyncio.wait_for(self._protocol._pending, timeout=10.0) + data = await asyncio.wait_for(future, timeout=5.0) return data, b'0' except asyncio.TimeoutError: - _LOGGER.error("Timeout: No response from server in 10 seconds.") + _LOGGER.error("Timeout: No response from server in 5 seconds.") return None + finally: + transport.close() async def send(self, bytes_to_send): - """Send bytes without waiting for a response.""" - self._transport.sendto(bytes_to_send) + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + lambda: self.SimpleClientProtocol(bytes_to_send), + remote_addr=(self.server_host, self.server_port) + ) + transport.close() From d0dad8dbd15dcc94e07b17eaedc8c53af7454923 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:40:52 +0200 Subject: [PATCH 17/41] auth update --- CHANGELOG.md | 6 ++++++ custom_components/alsavopro/AlsavoPyCtrl.py | 6 +++--- custom_components/alsavopro/manifest.json | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b96394..85619a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.0.6] - 2026-05-13 + +### Fixed +- Aligned auth challenge-response MD5 hash with the reference C++ implementation: token bytes are now packed little-endian (matching native x86 byte order used by the pump server) +- Expanded `clientToken` entropy from 16-bit to full 32-bit random value, matching the reference implementation + ## [1.0.5] - 2026-05-13 ### Fixed diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index d2aaa18..9547764 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -455,7 +455,7 @@ async def set_config(self, idx: int, value: int): async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug("Connecting to Alsavo Pro") - self.clientToken = random.randint(0, 65535) + self.clientToken = random.randint(0, 0xFFFFFFFF) self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) @@ -473,8 +473,8 @@ async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug(f"Received handshake, CSID={hex(self.CSID)}, DSID={hex(self.DSIS)}, "f"server token {hex(self.serverToken)}") ctx = hashlib.md5() - ctx.update(self.clientToken.to_bytes(4, "big")) - ctx.update(self.serverToken.to_bytes(4, "big")) + ctx.update(self.clientToken.to_bytes(4, "little")) + ctx.update(self.serverToken.to_bytes(4, "little")) ctx.update(md5_hash(self.password)) response = await self.send_auth_response(ctx) diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 708461d..c43614c 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.5" + "version": "1.0.6" } From 0c1c60d9d6cff006fbb034ed06963cf419922baa Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Wed, 13 May 2026 13:46:58 +0200 Subject: [PATCH 18/41] revert changes --- CHANGELOG.md | 6 ------ custom_components/alsavopro/AlsavoPyCtrl.py | 6 +++--- custom_components/alsavopro/manifest.json | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85619a1..7b96394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # Changelog -## [1.0.6] - 2026-05-13 - -### Fixed -- Aligned auth challenge-response MD5 hash with the reference C++ implementation: token bytes are now packed little-endian (matching native x86 byte order used by the pump server) -- Expanded `clientToken` entropy from 16-bit to full 32-bit random value, matching the reference implementation - ## [1.0.5] - 2026-05-13 ### Fixed diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 9547764..d2aaa18 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -455,7 +455,7 @@ async def set_config(self, idx: int, value: int): async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug("Connecting to Alsavo Pro") - self.clientToken = random.randint(0, 0xFFFFFFFF) + self.clientToken = random.randint(0, 65535) self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) @@ -473,8 +473,8 @@ async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug(f"Received handshake, CSID={hex(self.CSID)}, DSID={hex(self.DSIS)}, "f"server token {hex(self.serverToken)}") ctx = hashlib.md5() - ctx.update(self.clientToken.to_bytes(4, "little")) - ctx.update(self.serverToken.to_bytes(4, "little")) + ctx.update(self.clientToken.to_bytes(4, "big")) + ctx.update(self.serverToken.to_bytes(4, "big")) ctx.update(md5_hash(self.password)) response = await self.send_auth_response(ctx) diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index c43614c..708461d 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.6" + "version": "1.0.5" } From 523c41397fd6dd3e5f46c98e5020fdeb318017d1 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sat, 16 May 2026 16:23:08 +0200 Subject: [PATCH 19/41] Fix typo in async_unload_entry function --- custom_components/alsavopro/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index de30f58..14c9fb0 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - return await hass.config_entries.async_forward_entry_unloads( + return await hass.config_entries.async_forward_entry_unload( config_entry, ["climate", "sensor"] ) From bec36f2f4435ef2c48984ba777c24d60371011d3 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sat, 16 May 2026 22:09:52 +0200 Subject: [PATCH 20/41] Update __init__.py --- custom_components/alsavopro/__init__.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 14c9fb0..a383203 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -5,6 +5,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, + UpdateFailed, ) from homeassistant.const import ( @@ -55,24 +56,36 @@ async def async_unload_entry(hass, config_entry): ) +OFFLINE_TOLERANCE = 5 # consecutive failures before reporting unavailable + + class AlsavoProDataCoordinator(DataUpdateCoordinator): def __init__(self, hass, data_handler): """Initialize my coordinator.""" 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 async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: async with asyncio.timeout(10): 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: - _LOGGER.debug("_async_update_data timed out") + raise UpdateFailed(f"Alsavo Pro unreachable after {OFFLINE_TOLERANCE} attempts: {err}") from err From 334dd54170778f7685f8ed8e6d58547555d49451 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 09:46:24 +0200 Subject: [PATCH 21/41] log errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlsavoPyCtrl.py:41 — retry attempts now log at DEBUG instead of WARNING, so they won't flood the HA log during normal network hiccups AlsavoPyCtrl.py:44-45 — update() now raises ConnectionError after all retries fail, so the coordinator's OFFLINE_TOLERANCE (5 consecutive full failures = ~5 minutes) properly gates when HA marks the device unavailable udpclient.py:57 — UDP timeout demoted from ERROR to DEBUG --- custom_components/alsavopro/AlsavoPyCtrl.py | 16 +++++++++++----- custom_components/alsavopro/udpclient.py | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index d2aaa18..e426d3d 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -29,6 +29,7 @@ def __init__(self, name, serial_no, ip_address, port_no, password): async def update(self): _LOGGER.debug("update") + last_error = None for attempt in range(MAX_UPDATE_RETRIES): try: await self._session.connect(self._ip_address, int(self._port_no), int(self._serial_no), self._password) @@ -38,11 +39,12 @@ async def update(self): self._online = True return except Exception as e: - _LOGGER.warning(f"Update attempt {attempt + 1} failed: {e}") + last_error = e + _LOGGER.debug("Update attempt %d/%d failed: %s", attempt + 1, MAX_UPDATE_RETRIES, e) if attempt + 1 < MAX_UPDATE_RETRIES: await asyncio.sleep(2) - _LOGGER.error("Unable to update after max retries") self._online = False + raise ConnectionError(f"Unable to update after {MAX_UPDATE_RETRIES} retries: {last_error}") async def set_config(self, idx: int, value: int): _LOGGER.debug(f"set_config({idx}, {value})") @@ -360,13 +362,17 @@ 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: + elif payload.subType == 3: obj.__deviceInfo = payload obj.__payloads.append(payload) idx += payload.size + 8 diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 26a7c35..8830473 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -54,7 +54,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() From 2baafdabfd05e2069c9dab9db1369a095dc328cd Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 10:16:34 +0200 Subject: [PATCH 22/41] Fix practical bugs in protocol handling, climate entity, and config flow - Remove internal retry loops in AlsavoPyCtrl.update() and set_config(). The coordinator's 10s timeout was cancelling the 18+s internal retry loop, so retries 3-10 were dead code. The coordinator's OFFLINE_TOLERANCE is now the single retry layer. Bumped coordinator timeout to 15s. - Make set_config raise on failure instead of silently logging an error, so mode/temperature changes surface as errors in HA. - Implement async_turn_on/async_turn_off (TURN_ON|TURN_OFF features were declared but never implemented, which would NotImplementedError). - Replace status-index-based min_temp/max_temp with hardcoded limits per mode and dev_type (matches official Alsavo Pro Android app behaviour reverse-engineered from APK 1.8). dev_type-aware hvac_modes filter: SINGLE devices only get Heat; FIXCH/FREQCH don't get Auto. - Preset modes (Silent/Smart/Powerful) now only exposed for variable-frequency devices (dev_type 0 or 3), and the name list is derived from POWER_MODE_MAP instead of duplicated hardcoded strings. - Fix OptionsFlowHandler: previously showed a form but never persisted user input, silently discarding password changes. - unique_id now uses serial number only (was f"{name}-{serial_no}" where name is mutable user input). Broken duplicate detection that used OR across all fields (blocking legit second devices on same port) replaced with HA's built-in _abort_if_unique_id_configured(). - Add asyncio.Lock around read-modify-write on config register 4 (mode/power/timer bits) to prevent races. - Switch random.randint -> secrets.randbelow for auth client token. - Remove legacy async_setup; use async_unload_platforms on unload. - Lazy %s log formatting; remove dead async_update in climate entity; remove unused exception classes in config_flow. Co-Authored-By: Claude Opus 4.7 --- custom_components/alsavopro/AlsavoPyCtrl.py | 115 +++++++++------- custom_components/alsavopro/__init__.py | 36 ++--- custom_components/alsavopro/climate.py | 142 ++++++++++---------- custom_components/alsavopro/config_flow.py | 100 +++++--------- custom_components/alsavopro/const.py | 20 +++ 5 files changed, 215 insertions(+), 198 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index e426d3d..fa935b3 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 .const import MODE_TO_CONFIG, ALARM_REGISTER_48, ALARM_REGISTER_49, ALARM_REGISTER_50, 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,43 +31,37 @@ 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 update(self): _LOGGER.debug("update") - last_error = None - for attempt in range(MAX_UPDATE_RETRIES): - 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 - self._online = True - return - except Exception as e: - last_error = e - _LOGGER.debug("Update attempt %d/%d failed: %s", attempt + 1, MAX_UPDATE_RETRIES, e) - if attempt + 1 < MAX_UPDATE_RETRIES: - await asyncio.sleep(2) - self._online = False - raise ConnectionError(f"Unable to update after {MAX_UPDATE_RETRIES} retries: {last_error}") + try: + await self._session.connect( + self._ip_address, int(self._port_no), int(self._serial_no), self._password + ) + data = await 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})") - for attempt in range(MAX_SET_CONFIG_RETRIES): - 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) - self._online = True - return - except Exception as e: - _LOGGER.warning(f"Set config attempt {attempt + 1} failed: {e}") - if attempt + 1 < MAX_SET_CONFIG_RETRIES: - await asyncio.sleep(2) - _LOGGER.error(f"Unable to set config: {idx}, {value} after max retries") - self._online = False + _LOGGER.debug("set_config(%s, %s)", idx, value) + 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) + self._online = True + except Exception: + self._online = False + raise @property def is_online(self) -> bool: @@ -138,6 +140,16 @@ def is_timer_off_enabled(self): def manual_defrost(self): return self._data.get_config_value(5) & 1 == 1 + @property + 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_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 errors(self): errors = [] @@ -149,16 +161,24 @@ def errors(self): return "\n".join(errors) 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) @@ -293,7 +313,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] @@ -428,17 +448,17 @@ 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})") + _LOGGER.debug("send_packet(payload, %s)", 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) + await self.send(PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, len(payload)).pack() + payload) async def query_all(self): """ Query all information from the heat pump """ @@ -451,7 +471,7 @@ async def query_all(self): 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') @@ -461,7 +481,7 @@ async def set_config(self, idx: int, value: int): async def connect(self, server_ip, server_port, serial, password): _LOGGER.debug("Connecting to Alsavo Pro") - self.clientToken = random.randint(0, 65535) + self.clientToken = secrets.randbelow(65536) self.serialQ = serial self.password = password self.client = UDPClient(server_ip, server_port) @@ -476,7 +496,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")) @@ -485,7 +508,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 a383203..623a700 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -23,9 +23,13 @@ _LOGGER = logging.getLogger(__name__) +PLATFORMS = ["sensor", "climate"] -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): @@ -40,23 +44,19 @@ async def async_setup_entry(hass, entry): await data_handler.update() 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.""" - return await hass.config_entries.async_forward_entry_unload( - config_entry, ["climate", "sensor"] - ) - - -OFFLINE_TOLERANCE = 5 # consecutive failures before reporting unavailable + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id, None) + return unloaded class AlsavoProDataCoordinator(DataUpdateCoordinator): @@ -74,10 +74,10 @@ def __init__(self, hass, data_handler): async def _async_update_data(self): _LOGGER.debug("_async_update_data") try: - async with asyncio.timeout(10): + async with asyncio.timeout(UPDATE_TIMEOUT): await self.data_handler.update() - self._consecutive_failures = 0 - return self.data_handler + self._consecutive_failures = 0 + return self.data_handler except Exception as err: self._consecutive_failures += 1 if self._consecutive_failures < OFFLINE_TOLERANCE: @@ -88,4 +88,6 @@ async def _async_update_data(self): err, ) return self.data_handler - raise UpdateFailed(f"Alsavo Pro unreachable after {OFFLINE_TOLERANCE} attempts: {err}") from err + raise UpdateFailed( + f"Alsavo Pro unreachable after {OFFLINE_TOLERANCE} attempts: {err}" + ) from err diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 6979ef5..0fee918 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -18,11 +18,34 @@ from . import AlsavoProDataCoordinator 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])]) @@ -31,143 +54,124 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlsavoProClimate(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 - | ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF - ) - @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, } - - if not self._data_handler.is_power_on: - return HVACMode.OFF - return operating_mode_map.get(self._data_handler.operating_mode, HVACMode.OFF) + @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: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() + await self.coordinator.async_request_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() + await self.coordinator.async_request_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, } - action = hvac_mode_actions.get(hvac_mode) - if action: - await action() - await self.coordinator.async_request_refresh() + if action is None: + return + await action() + await self.coordinator.async_request_refresh() async def async_set_preset_mode(self, preset_mode): - """Set hvac preset mode.""" - preset_mode_to_power_mode = { - 'Silent': 0, - 'Smart': 1, - 'Powerful': 2 - } - - 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) + await self.coordinator.async_request_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 1.0 - 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 diff --git a/custom_components/alsavopro/config_flow.py b/custom_components/alsavopro/config_flow.py index 0442436..5ecf47f 100755 --- a/custom_components/alsavopro/config_flow.py +++ b/custom_components/alsavopro/config_flow.py @@ -1,17 +1,17 @@ """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, ) DATA_SCHEMA = vol.Schema( @@ -25,24 +25,6 @@ ) -async def validate_input(hass: core.HomeAssistant, name, serial_no, ip_address, port_no, password): - """Validate the user input allows us to connect.""" - - 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.") - - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] """Handle a config flow for Alsavo Pro pool heater integration.""" @@ -53,34 +35,26 @@ async def async_step_user(self, user_input=None): 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" - except MissingPasswordValue: - errors["base"] = "missing_password" + 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", @@ -91,30 +65,24 @@ async def async_step_user(self, user_input=None): @staticmethod @callback def async_get_options_flow(config_entry): - return OptionsFlowHandler() + 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, }), ) - - -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 password is missing.""" diff --git a/custom_components/alsavopro/const.py b/custom_components/alsavopro/const.py index 8bf4514..886cc7b 100755 --- a/custom_components/alsavopro/const.py +++ b/custom_components/alsavopro/const.py @@ -64,3 +64,23 @@ # Max retries MAX_UPDATE_RETRIES = 10 MAX_SET_CONFIG_RETRIES = 10 + +# 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 + +# 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 From 908cfb4d5ee2e9aa9c209ceb61adfa677f1380a3 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 10:27:40 +0200 Subject: [PATCH 23/41] Cleanup pass: remove dead code and fix target_temperature fallback - target_temperature: return None when operating_mode has no mapping, instead of falling through to config[0] which is unrelated data (would have shown a bogus value). - Remove no-op async_update in AlsavoProErrorSensor (same dead reassign pattern that was removed from climate.py). - Remove unused properties on AlsavoPro that were defined but never referenced anywhere: water_out_temperature, ambient_temperature, is_timer_on_enabled, water_pump_running_mode, electronic_valve_style, is_debug_mode, is_timer_off_enabled, manual_defrost. The last one checked config_sys2 bit 0 which has no documented meaning in the official app's SDK either. - Remove the ConnectionStatus Enum (unused) and lstConfigReqTime timestamp field (set but never read). - Remove __payloads list and __deviceInfo field on QueryResponse (never read outside the class). - Remove MAX_UPDATE_RETRIES and MAX_SET_CONFIG_RETRIES from const.py (no longer used after retry loops were removed). - Drop unused `from enum import Enum` import. - Use ConnectionError instead of bare Exception in query_all. Co-Authored-By: Claude Opus 4.7 --- custom_components/alsavopro/AlsavoPyCtrl.py | 59 +++------------------ custom_components/alsavopro/const.py | 4 -- custom_components/alsavopro/sensor.py | 4 -- 3 files changed, 8 insertions(+), 59 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index fa935b3..f3aeafe 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -4,7 +4,7 @@ import secrets import struct from datetime import datetime, timezone -from enum import Enum + from .const import ( MODE_TO_CONFIG, ALARM_REGISTER_48, @@ -73,7 +73,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) @@ -96,30 +99,10 @@ 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 - - @property - def water_pump_running_mode(self): - return self._data.get_config_value(4) & 8 == 8 - - @property - def electronic_valve_style(self): - return self._data.get_config_value(4) & 16 == 16 - @property def is_power_on(self): return self._data.get_config_value(4) & 32 == 32 @@ -128,18 +111,6 @@ def is_power_on(self): def power_mode(self): return self._data.get_config_value(16) - @property - def is_debug_mode(self): - return self._data.get_config_value(4) & 64 == 64 - - @property - def is_timer_off_enabled(self): - return self._data.get_config_value(4) & 128 == 128 - - @property - def manual_defrost(self): - return self._data.get_config_value(5) & 1 == 1 - @property def dev_type(self): """Device type from status register (see DEV_TYPE_* in const.py).""" @@ -339,22 +310,18 @@ class QueryResponse: def __init__(self, action, parts): self.action = action self.parts = parts - self.__payloads = [] self.__status = None self.__config = None - self.__deviceInfo = 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) @@ -392,9 +359,6 @@ def unpack(data): obj.__status = payload elif payload.subType == 2: obj.__config = payload - elif payload.subType == 3: - obj.__deviceInfo = payload - obj.__payloads.append(payload) idx += payload.size + 8 return obj @@ -407,11 +371,6 @@ 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. """ @@ -423,7 +382,6 @@ def __init__(self): self.password = None self.serialQ = None self.clientToken = None - self.lstConfigReqTime = None self.client = None async def send_and_receive(self, bytes_to_send): @@ -464,9 +422,8 @@ 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") + raise ConnectionError("query_all: no response") return QueryResponse.unpack(resp[0][16:]) async def set_config(self, idx: int, value: int): diff --git a/custom_components/alsavopro/const.py b/custom_components/alsavopro/const.py index 886cc7b..8412660 100755 --- a/custom_components/alsavopro/const.py +++ b/custom_components/alsavopro/const.py @@ -61,10 +61,6 @@ 0x0400: "PP11: Water temperature (T2) too low protection (cooling mode)", } -# Max retries -MAX_UPDATE_RETRIES = 10 -MAX_SET_CONFIG_RETRIES = 10 - # Device type (status register 64). Values reverse-engineered from the official app: # 0 = FREQALL (variable-frequency, all modes) # 1 = SINGLE (heat-only) diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 36d7341..2d5a749 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -431,7 +431,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 From 04882bce1e5038cd244e598bac2391a61cd62c7f Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 12:13:59 +0200 Subject: [PATCH 24/41] docs: remove dead cloud-relay config and add cloud-retry troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the cloud-relay configuration option (47.254.157.150:51192). That endpoint is no longer reachable; the regional GalaxyWind dispatchers appear to be retired in some regions. The integration has always worked against the pump's LAN IP, so only document that. - Add a Troubleshooting section explaining: - "Offline in app, online in HA" is normal — the app uses the cloud, we use direct UDP. - Intermittent local timeouts are caused by the pump's WiFi module looping on cloud retries (47.88.188.100, hardcoded in firmware as EU/AU/BR fallback). Mitigation: firewall REJECT (not DROP) outbound traffic from the pump to that IP and to *.ice.galaxywind.com. --- README.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 57291cc..942f320 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,30 @@ Restart Home Assistant and go to *Devices and Services* and press *+Add integrat 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 +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 From c02cbe4b3d41f95e46c51634c4baf18ee041db15 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 12:20:27 +0200 Subject: [PATCH 25/41] Persist the auth session across calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, every status poll and every config write called AlsavoSocketCom.connect(), which redoes the full UDP handshake (auth challenge → response → auth ack) before sending the actual request. That's 4 extra UDP roundtrips per minute and a much larger failure surface during slow periods — especially relevant for users whose pump WiFi module is looping on cloud-reconnect attempts and starving local UDP. The official Android app's native library shows the pump's protocol supports session reuse: get keeplive reset send keeplive timer_keeplive Drop bad packet: packet session id=%08x, but now is %08x So we now hold CSID/DSID across calls and only re-auth when the session goes stale. - connect() is idempotent: returns immediately if a session exists. - disconnect() drops session state for explicit reset. - A partial-handshake failure cleans up so we don't appear connected. - update() and set_config() go through _with_session_retry(), which invokes the operation once on the existing session; on any failure it tears the session down, re-auths, and retries the operation once. This matches the happy path of the official app (1 request, 1 reply per poll) while keeping the same worst-case behaviour we had before (full handshake + retry on failure). Co-Authored-By: Claude Opus 4.7 --- custom_components/alsavopro/AlsavoPyCtrl.py | 65 +++++++++++++++++---- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index f3aeafe..1c282d6 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -35,13 +35,34 @@ def __init__(self, name, serial_no, ip_address, port_no, password): # 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. Mirrors how the official app reuses sessions + between requests instead of re-authing every call. + """ + 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 self._ensure_connected() + return await op(*args) + async def update(self): _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() + data = await self._with_session_retry(self._session.query_all) except Exception: self._online = False raise @@ -54,10 +75,7 @@ async def update(self): async def set_config(self, idx: int, value: int): _LOGGER.debug("set_config(%s, %s)", idx, value) 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) + await self._with_session_retry(self._session.set_config, idx, value) self._online = True except Exception: self._online = False @@ -372,8 +390,13 @@ def md5_hash(text): 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 @@ -384,6 +407,18 @@ def __init__(self): self.clientToken = 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("send_and_receive()") response = await self.client.send_rcv(bytes_to_send) @@ -436,8 +471,18 @@ async def set_config(self, idx: int, value: int): 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) async def connect(self, server_ip, server_port, serial, password): + if self.is_connected: + return _LOGGER.debug("Connecting to Alsavo Pro") + 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 From 569300a625141f0880bdb98b75db5f9aeaaae7cb Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:09:57 +0200 Subject: [PATCH 26/41] Fix startup failure when pump is unreachable at HA boot Raise ConfigEntryNotReady instead of propagating the raw exception so HA retries setup with exponential backoff rather than marking the entry as broken. Add a 2-second sleep before the session retry to give the pump time to clear any half-open state from the first failed handshake attempt. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 1 + custom_components/alsavopro/__init__.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 1c282d6..84b0bdc 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -56,6 +56,7 @@ async def _with_session_retry(self, op, *args): "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) diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 623a700..7d1a2f7 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -3,6 +3,7 @@ import logging from datetime import timedelta +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, @@ -41,7 +42,10 @@ 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) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator From 90eeb2e51bb41f3e2c11115d082f3d5b4b42f88b Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:24:05 +0200 Subject: [PATCH 27/41] Add 5-second follow-up refresh after each control command The official app suppresses incoming data-update events for 3 s after a user interaction, then re-reads settled device state. We replicate this by scheduling a second coordinator refresh 5 s after every set_* call, in addition to the existing immediate refresh. If two commands arrive in quick succession the pending follow-up is cancelled and the 5 s window resets, preventing back-to-back polls. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/__init__.py | 10 ++++++++++ custom_components/alsavopro/climate.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 7d1a2f7..93d74fa 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -74,6 +74,16 @@ def __init__(self, hass, data_handler): ) 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() + self._followup_cancel = self.hass.loop.call_later( + 5, + lambda: self.hass.async_create_task(self.async_request_refresh()), + ) async def _async_update_data(self): _LOGGER.debug("_async_update_data") diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 0fee918..2c73cf8 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -123,10 +123,12 @@ def icon(self): async def async_turn_on(self): await self._data_handler.set_power_on() await self.coordinator.async_request_refresh() + self.coordinator.schedule_followup_refresh() async def async_turn_off(self): await self._data_handler.set_power_off() await self.coordinator.async_request_refresh() + self.coordinator.schedule_followup_refresh() async def async_set_hvac_mode(self, hvac_mode): hvac_mode_actions = { @@ -140,6 +142,7 @@ async def async_set_hvac_mode(self, hvac_mode): return await action() await self.coordinator.async_request_refresh() + self.coordinator.schedule_followup_refresh() async def async_set_preset_mode(self, preset_mode): power_mode = _PRESET_TO_POWER_MODE.get(preset_mode) @@ -147,6 +150,7 @@ async def async_set_preset_mode(self, preset_mode): return await self._data_handler.set_power_mode(power_mode) await self.coordinator.async_request_refresh() + self.coordinator.schedule_followup_refresh() @property def min_temp(self): @@ -175,3 +179,4 @@ async def async_set_temperature(self, **kwargs): return await self._data_handler.set_target_temperature(temperature) await self.coordinator.async_request_refresh() + self.coordinator.schedule_followup_refresh() From c798e96a729a3cd6162b4155e49fc22141d6b27f Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:33:20 +0200 Subject: [PATCH 28/41] Fix follow-up refresh timer and silent zero-value responses Two bugs: 1. schedule_followup_refresh called the TimerHandle as a function instead of calling .cancel() on it. This raised TypeError on any second command after the 5 s window had already fired. Also clear _followup_cancel when the timer fires so the next command starts from a clean state. 2. QueryResponse.unpack silently returned an all-zero object when the pump returned an unexpected packet (e.g. a stale ACK received by the query socket). Added QueryResponse.is_valid and raised in query_all so _with_session_retry re-auths and retries instead of storing zeros. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 9 ++++++++- custom_components/alsavopro/__init__.py | 13 ++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 84b0bdc..effe9c9 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -332,6 +332,10 @@ def __init__(self, action, parts): self.__status = None self.__config = 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 @@ -460,7 +464,10 @@ async def query_all(self): resp = await self.send_and_rcv_packet(b'\x08\x01\x00\x00\x00\x02\x00\x2e\xff\xff\x00\x00') if resp is None: raise ConnectionError("query_all: no response") - return QueryResponse.unpack(resp[0][16:]) + 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 """ diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 93d74fa..d2cc9c8 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -79,11 +79,14 @@ def __init__(self, hass, data_handler): 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() - self._followup_cancel = self.hass.loop.call_later( - 5, - lambda: self.hass.async_create_task(self.async_request_refresh()), - ) + 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) async def _async_update_data(self): _LOGGER.debug("_async_update_data") From 57c6cbaacfbb59806ea39d404f7d330ff827162e Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:36:05 +0200 Subject: [PATCH 29/41] Re-auth on every call: pump session TTL < poll interval The pcap showed every poll cycle does a full re-auth, confirming the pump's session timeout is shorter than 60 s. The persistent-session optimisation was therefore always hitting the retry path: 5 s UDP timeout + 2 s sleep before the inevitable re-auth on every single poll. Fix: remove the is_connected guard in connect() so each operation authenticates fresh. The _with_session_retry wrapper remains for genuine transient failures (packet loss during auth). Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index effe9c9..b82d054 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -43,10 +43,9 @@ async def _ensure_connected(self): 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. Mirrors how the official app reuses sessions - between requests instead of re-authing every call. + Auth, run `op(*args)`, retry once on transient failure. + The pump's session TTL is shorter than the poll interval so we + re-auth on every call; the retry handles rare mid-auth packet loss. """ try: await self._ensure_connected() @@ -479,8 +478,6 @@ async def set_config(self, idx: int, value: int): 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) async def connect(self, server_ip, server_port, serial, password): - if self.is_connected: - return _LOGGER.debug("Connecting to Alsavo Pro") try: await self._do_handshake(server_ip, server_port, serial, password) From cb73ee8b6440481b5e68236b2caa1970a5193aef Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:39:00 +0200 Subject: [PATCH 30/41] Revert "Re-auth on every call: pump session TTL < poll interval" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live debug logs show the pump maintains sessions well beyond 60 s. At 13:34:36 the session was established; at 13:35:07 a set_config and immediate refresh both reused it (~10 ms each vs ~56 ms for auth). The "always re-auth" change was a wrong diagnosis — the persistent session is working correctly and should be kept. This reverts commit 57c6cba. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index b82d054..effe9c9 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -43,9 +43,10 @@ async def _ensure_connected(self): async def _with_session_retry(self, op, *args): """ - Auth, run `op(*args)`, retry once on transient failure. - The pump's session TTL is shorter than the poll interval so we - re-auth on every call; the retry handles rare mid-auth packet loss. + 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. Mirrors how the official app reuses sessions + between requests instead of re-authing every call. """ try: await self._ensure_connected() @@ -478,6 +479,8 @@ async def set_config(self, idx: int, value: int): 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) async def connect(self, server_ip, server_port, serial, password): + if self.is_connected: + return _LOGGER.debug("Connecting to Alsavo Pro") try: await self._do_handshake(server_ip, server_port, serial, password) From 1a926bc18d017da3a2d73cadb0f3a41aace626c8 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 13:50:16 +0200 Subject: [PATCH 31/41] Drain pump write-ACK in set_config to prevent query_all collision The pump queues a write-ACK for each set_config command and delivers it to the next socket that contacts the session. Since set_config used send_packet (fire-and-forget, socket closed immediately), the ACK had nowhere to go and was held by the pump. The subsequent query_all opened a new socket; the pump sent the queued ACK there first, which EchoClientProtocol captured as the result, causing is_valid to fail and triggering a 2-second re-auth cycle on every command+refresh pair. Fix: switch set_config to send_and_rcv_packet so the ACK is consumed in-band. The return value is discarded; we rely on the follow-up query_all to verify the new state. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index effe9c9..eed8dbe 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -476,7 +476,11 @@ async def set_config(self, idx: int, value: int): 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) + # Use send_and_rcv_packet (not send_packet) to consume the pump's + # write-ACK. The pump queues the ACK and delivers it to the next socket + # that contacts the session; if left unconsumed, query_all captures it + # instead of the data response and triggers a spurious re-auth cycle. + 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: From 82a353b7c3786696e10d2f5877da2af2e6fcc92b Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 14:06:44 +0200 Subject: [PATCH 32/41] Re-auth before every set_config; drop the immediate refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pump only commits config writes when they arrive on a freshly authenticated session. After the persistent-session refactor, writes were going out on a reused CSID/DSID: the pump ACKed them but never applied the value. The baseline always did a full handshake before every write — restore that, but only for writes (reads still reuse the session across the 60 s poll). Also drop the immediate async_request_refresh() that ran right after every command. It queried the pump 1-14 ms after the write-ACK, before the pump had committed the new register value, and overwrote the coordinator cache with stale state — making the UI appear to "snap back" to the old value. The 5 s schedule_followup_refresh() matches the official app's 3 s UI-suppression window and is enough on its own. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 5 +++++ custom_components/alsavopro/climate.py | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index eed8dbe..0bfa20a 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -75,6 +75,11 @@ async def update(self): async def set_config(self, idx: int, value: int): _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._with_session_retry(self._session.set_config, idx, value) self._online = True diff --git a/custom_components/alsavopro/climate.py b/custom_components/alsavopro/climate.py index 2c73cf8..b4882cd 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -122,12 +122,10 @@ def icon(self): async def async_turn_on(self): await self._data_handler.set_power_on() - await self.coordinator.async_request_refresh() self.coordinator.schedule_followup_refresh() async def async_turn_off(self): await self._data_handler.set_power_off() - await self.coordinator.async_request_refresh() self.coordinator.schedule_followup_refresh() async def async_set_hvac_mode(self, hvac_mode): @@ -141,7 +139,6 @@ async def async_set_hvac_mode(self, hvac_mode): if action is None: return await action() - await self.coordinator.async_request_refresh() self.coordinator.schedule_followup_refresh() async def async_set_preset_mode(self, preset_mode): @@ -149,7 +146,6 @@ async def async_set_preset_mode(self, preset_mode): if power_mode is None: return await self._data_handler.set_power_mode(power_mode) - await self.coordinator.async_request_refresh() self.coordinator.schedule_followup_refresh() @property @@ -178,5 +174,4 @@ async def async_set_temperature(self, **kwargs): if temperature is None: return await self._data_handler.set_target_temperature(temperature) - await self.coordinator.async_request_refresh() self.coordinator.schedule_followup_refresh() From aa9c497524aa14a0c5f23c91d015892d6d3e089f Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 14:08:59 +0200 Subject: [PATCH 33/41] Remove changelog from README Removed changelog section detailing previous versions. --- README.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/README.md b/README.md index 942f320..786c6ca 100644 --- a/README.md +++ b/README.md @@ -169,28 +169,6 @@ Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. | Alarm code 1–4 | Raw alarm register values (registers 48–51) | | Error messages | Decoded human-readable alarm messages | -## Changelog - -### 1.0.4 -- Added Auto HVAC mode (maps to pump's internal auto mode) -- Added 18 new sensors: compressor input temp, EEV opening, compressor speed, device status code, heating max/cooling min temps, manual settings, defrost config, timer config, and more -- Fixed `ClimateEntityFeature.TURN_ON`/`TURN_OFF` missing from supported features (required in HA 2024.2+) -- Fixed `hvac_mode` returning `None` for unknown operating modes, now falls back to `HVACMode.OFF` -- Fixed `AlsavoProErrorSensor` missing `available` property, entity now correctly reflects online/offline state -- Removed unused imports in `climate.py` and `sensor.py` - -### 1.0.3 -- Full alarm code decoding for all EE (EE01–EE28) and PP (PP01–PP11) fault codes across registers 48–50 - -### 1.0.2 -- Fixed `set_config` recursive retry replaced with iterative loop to prevent stack overflow and stale `_online` state -- Fixed `is_online` now correctly reflects live connection state instead of stale data presence -- Fixed `Payload.get_value` off-by-one bounds check - -### 1.0.1 -- Fixed `NoneType object is not subscriptable` crash when pump is temporarily offline during auth challenge -- Fixed `unpack requires a buffer of X bytes` error when receiving truncated UDP packets -- Added 2-second delay between update retries so the pump has time to recover when briefly offline ## AlsavoCtrl This code is very much based on AlsavoCtrl: https://github.com/strandborg/AlsavoCtrl From 3298d66881898aa64973a180458a0db97b8f17ed Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 14:15:16 +0200 Subject: [PATCH 34/41] Drop the session after each write to skip the post-write retry path The pump invalidates the session immediately after a config write: the next query on the same CSID/DSID returns a truncated packet that fails parsing, and _with_session_retry recovers by sleeping 2 s + re-authing. The data ends up correct, but every command takes ~7 s end-to-end. Disconnect the session right after a successful write so the follow-up read does a fast ~50 ms fresh handshake instead. Brings command-to-UI latency back down to ~5 s, matching the schedule_followup_refresh delay. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 0bfa20a..b1aecc6 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -86,6 +86,12 @@ async def set_config(self, idx: int, value: int): 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: From a0fc3bd51788a9e3164d12878de8699d3924b51d Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 14:54:30 +0200 Subject: [PATCH 35/41] Post-refactor cleanup: drop dead code, fix stale comments, cancel followup on unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After today's session-handling work, four small leftovers: - AlsavoSocketCom.send_packet, AlsavoSocketCom.send, UDPClient.send and UDPClient.SimpleClientProtocol are no longer called. The only sender path left is send_and_rcv_packet → UDPClient.send_rcv. Delete them. - The comment on socket.set_config explained the old "drain ACK so the next socket on this session doesn't grab it" rationale; with the new disconnect-after-write logic the next call always opens a new session, so the rationale for send_and_rcv_packet is now just "confirm the write landed before the caller schedules a follow-up poll". Updated. - The _with_session_retry docstring claimed we "reuse sessions instead of re-authing every call" — true for reads, but writes now disconnect first. Clarified. - AlsavoProDataCoordinator schedules a one-shot follow-up refresh via loop.call_later(). If the config entry is unloaded during that 5 s window, the timer fires against a torn-down coordinator. Added a shutdown() that cancels the handle, called from async_unload_entry. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 20 +++++--------------- custom_components/alsavopro/__init__.py | 10 +++++++++- custom_components/alsavopro/udpclient.py | 19 ------------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index b1aecc6..1ff2819 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -45,8 +45,9 @@ 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. Mirrors how the official app reuses sessions - between requests instead of re-authing every call. + 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() @@ -441,10 +442,6 @@ async def send_and_receive(self, bytes_to_send): _LOGGER.debug("Received response") return response - async def send(self, bytes_to_send): - _LOGGER.debug("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())) @@ -464,11 +461,6 @@ async def send_and_rcv_packet(self, payload: bytes, cmd=0xf4): ) return None - async def send_packet(self, payload: bytes, cmd=0xf4): - _LOGGER.debug("send_packet(payload, %s)", cmd) - if self.CSID is not None and self.DSIS is not None: - await self.send(PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, len(payload)).pack() + payload) - async def query_all(self): """ Query all information from the heat pump """ _LOGGER.debug("socket.query_all") @@ -487,10 +479,8 @@ async def set_config(self, idx: int, value: int): 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') - # Use send_and_rcv_packet (not send_packet) to consume the pump's - # write-ACK. The pump queues the ACK and delivers it to the next socket - # that contacts the session; if left unconsumed, query_all captures it - # instead of the data response and triggers a spurious re-auth cycle. + # 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): diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index d2cc9c8..316201c 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -59,7 +59,9 @@ async def async_unload_entry(hass, entry): """Unload a config entry.""" unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unloaded: - hass.data[DOMAIN].pop(entry.entry_id, None) + coordinator = hass.data[DOMAIN].pop(entry.entry_id, None) + if coordinator is not None: + coordinator.shutdown() return unloaded @@ -88,6 +90,12 @@ def _fire(): 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: diff --git a/custom_components/alsavopro/udpclient.py b/custom_components/alsavopro/udpclient.py index 8830473..fc01461 100644 --- a/custom_components/alsavopro/udpclient.py +++ b/custom_components/alsavopro/udpclient.py @@ -9,17 +9,6 @@ def __init__(self, server_host, server_port): self.server_host = server_host self.server_port = server_port - 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 def __init__(self, message, future): @@ -58,11 +47,3 @@ async def send_rcv(self, bytes_to_send): return None finally: transport.close() - - async def send(self, bytes_to_send): - loop = asyncio.get_running_loop() - transport, protocol = await loop.create_datagram_endpoint( - lambda: self.SimpleClientProtocol(bytes_to_send), - remote_addr=(self.server_host, self.server_port) - ) - transport.close() From 8779dc687a09f20372d63dd647aeaba7fb5b4e57 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 16:09:31 +0200 Subject: [PATCH 36/41] Show Hot/Cold over as signed integers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These registers (config idx 14/15) carry a hysteresis-style offset that can legitimately be negative. The sensor was reading them as raw unsigned 16-bit values, so Cold over showed as 65516 instead of -20 (and Hot over only escaped by being positive). Add a `signed` flag to AlsavoProSensor that switches to get_signed_{status,config}_value when set, and expose the signed getters on AlsavoPro. Marking Hot over signed too keeps the pair consistent — same register family, same interpretation. Conservative fix: keeps the raw integer scale (no °C / ÷10) since the register's exact semantics aren't documented. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 6 ++++++ custom_components/alsavopro/sensor.py | 24 ++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 1ff2819..c559482 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -120,6 +120,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) diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 2d5a749..3d3dd5a 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -305,14 +305,16 @@ async def async_setup_entry(hass, entry, async_add_entities): "", 14, True, - "mdi:thermometer-high"), + "mdi:thermometer-high", + signed=True), AlsavoProSensor(coordinator, None, "Cold over", "", 15, True, - "mdi:thermometer-low"), + "mdi:thermometer-low", + signed=True), AlsavoProSensor(coordinator, None, "Unknown config 17", @@ -354,7 +356,8 @@ def __init__(self, coordinator: AlsavoProDataCoordinator, unit: str, idx: int, from_config: bool, - icon: str): + icon: str, + signed: bool = False): super().__init__(coordinator) self.data_coordinator = coordinator self._data_handler = self.data_coordinator.data_handler @@ -364,6 +367,7 @@ def __init__(self, coordinator: AlsavoProDataCoordinator, self._dataIdx = idx self._config = from_config self._icon = icon + self._signed = signed @property def name(self): @@ -384,17 +388,17 @@ 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): From 0a5ed4c71d1c92b496e0d00958fa6ae14e5b4651 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 16:23:25 +0200 Subject: [PATCH 37/41] Expose writeable settings from the official Android app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The official app's HtcHpParamActivity and HtcHpTimerActivity write to a handful of config registers that this integration only read out as sensors. Decoded them from the APK's ControlApi.java and added matching HA entities for each. New platforms: - number.py — 5 NumberEntity instances with the app's exact min/max: defrost in temp (idx 9, -30..0 °C, step 1) defrost out temp (idx 10, 2..30 °C, step 1) defrost in time (idx 12, 30..90 min) defrost out time (idx 13, 1..12 min) water compensation (idx 11, -9..9 °C, step 0.1) - switch.py — 3 SwitchEntity instances for the bit flags in config register 4: timer on enabled (bit 2) timer off enabled (bit 7) pump continuous run (bit 3) - time.py — 2 TimeEntity instances for the daily timer schedule: timer on time (idx 33, encoded as hour<<8 | minute) timer off time (idx 34, same encoding) AlsavoPyCtrl gains a `_toggle_config4_bit` helper that takes the existing `_config4_lock` so the new bit-flip writes don't race with mode/power changes. Each new entity's set call schedules the existing 5 s follow-up refresh so HA sees the settled state without bouncing the user through a re-auth cycle. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/alsavopro/AlsavoPyCtrl.py | 101 +++++++++++++++ custom_components/alsavopro/__init__.py | 2 +- custom_components/alsavopro/number.py | 135 ++++++++++++++++++++ custom_components/alsavopro/switch.py | 82 ++++++++++++ custom_components/alsavopro/time.py | 76 +++++++++++ 5 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 custom_components/alsavopro/number.py create mode 100644 custom_components/alsavopro/switch.py create mode 100644 custom_components/alsavopro/time.py diff --git a/custom_components/alsavopro/AlsavoPyCtrl.py b/custom_components/alsavopro/AlsavoPyCtrl.py index 1ff2819..10ddcaa 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -185,6 +185,107 @@ async def set_auto_mode(self): 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 diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 316201c..9af2107 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "climate"] +PLATFORMS = ["sensor", "climate", "number", "switch", "time"] # A single update() call performs the full UDP handshake + query. Allow generous # headroom so a momentarily-slow device doesn't get cancelled mid-handshake. diff --git a/custom_components/alsavopro/number.py b/custom_components/alsavopro/number.py new file mode 100644 index 0000000..3332e9e --- /dev/null +++ b/custom_components/alsavopro/number.py @@ -0,0 +1,135 @@ +"""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.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator +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(CoordinatorEntity, NumberEntity): + _attr_mode = NumberMode.BOX + + 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/switch.py b/custom_components/alsavopro/switch.py new file mode 100644 index 0000000..450b3c5 --- /dev/null +++ b/custom_components/alsavopro/switch.py @@ -0,0 +1,82 @@ +"""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.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator +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(CoordinatorEntity, SwitchEntity): + 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..100db58 --- /dev/null +++ b/custom_components/alsavopro/time.py @@ -0,0 +1,76 @@ +"""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.update_coordinator import CoordinatorEntity + +from . import AlsavoProDataCoordinator +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(CoordinatorEntity, TimeEntity): + 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() From 60d1efb8e2e0942a05e6e41d87fbb9bb1965de29 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sun, 17 May 2026 16:37:44 +0200 Subject: [PATCH 38/41] docs: 1.1.0 changelog + README update for writeable settings Bump manifest to 1.1.0 and document everything that landed today: - New number/switch/time platforms (10 entities) - HVAC modes filtered per device type - Cold over signed fix - Protocol/session bugfixes (writes-not-applied, silent zeros, stale-packet retry, follow-up timer leak) - Persistent reads + per-write re-handshake Also adds a "Tuning for winter operation" section to the README with sensible defrost parameter starting points for NW-European climate. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 32 ++++++++++++++ README.md | 51 +++++++++++++++++++++-- custom_components/alsavopro/manifest.json | 2 +- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b96394..8a78d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [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 diff --git a/README.md b/README.md index 786c6ca..4bdd930 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,52 @@ The integration exposes a climate entity with the following HVAC modes: | Auto | Automatic mode (heat or cool as needed) | | Off | Power off | -Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. +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 @@ -151,8 +196,8 @@ Preset modes control fan/compressor power: **Silent**, **Smart**, **Powerful**. | 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 threshold | -| Cold over | Low temperature threshold | +| 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 | diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index 708461d..de941ff 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.0.5" + "version": "1.1.0" } From b8dfa71888338eb38fe01ac175bec685fa5d4c84 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:21:00 +0200 Subject: [PATCH 39/41] Group entities into HA device-page categories HA's device page splits entities into Controls / Sensors / Configuration / Diagnostic sections based on each entity's entity_category. Everything was landing in one flat "Sensors" list because no category was set. - Writeable settings (number/switch/time) -> EntityCategory.CONFIG - Diagnostic readouts (codes, firmware, manual settings, defrost params, pipe/IPM/exhaust temps, alarm registers, hot/cold over, timers, clock) -> EntityCategory.DIAGNOSTIC - Primary metrics stay uncategorised so they show at the top: water in/out, ambient, mode targets, fan speed, compressor current/frequency, error messages Co-Authored-By: Claude Opus 4.8 --- custom_components/alsavopro/number.py | 2 + custom_components/alsavopro/sensor.py | 116 ++++++++++++++++++-------- custom_components/alsavopro/switch.py | 3 + custom_components/alsavopro/time.py | 3 + 4 files changed, 87 insertions(+), 37 deletions(-) diff --git a/custom_components/alsavopro/number.py b/custom_components/alsavopro/number.py index 3332e9e..d5bbc25 100644 --- a/custom_components/alsavopro/number.py +++ b/custom_components/alsavopro/number.py @@ -8,6 +8,7 @@ NumberMode, ) from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AlsavoProDataCoordinator @@ -101,6 +102,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlsavoProNumber(CoordinatorEntity, NumberEntity): _attr_mode = NumberMode.BOX + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoNumberSpec): super().__init__(coordinator) diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 3d3dd5a..08b702c 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -4,6 +4,7 @@ ) from homeassistant.const import UnitOfTemperature +from homeassistant.helpers.entity import EntityCategory from . import AlsavoProDataCoordinator from .const import ( @@ -14,6 +15,9 @@ CoordinatorEntity, ) +# Shorthand so the long sensor list below stays readable. +DIAG = EntityCategory.DIAGNOSTIC + async def async_setup_entry(hass, entry, async_add_entities): coordinator = hass.data[DOMAIN][entry.entry_id] @@ -46,28 +50,32 @@ async def async_setup_entry(hass, entry, async_add_entities): UnitOfTemperature.CELSIUS, 19, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "heating pipe", UnitOfTemperature.CELSIUS, 20, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "IPM module", UnitOfTemperature.CELSIUS, 21, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Exhaust temperature", UnitOfTemperature.CELSIUS, 23, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Heating mode target", @@ -116,84 +124,96 @@ async def async_setup_entry(hass, entry, async_add_entities): "", 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"), + "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", @@ -207,98 +227,112 @@ async def async_setup_entry(hass, entry, async_add_entities): UnitOfTemperature.CELSIUS, 24, False, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, None, "EEV opening", "", 25, False, - "mdi:valve"), + "mdi:valve", + category=DIAG), AlsavoProSensor(coordinator, None, "Compressor speed setting", "", 33, False, - "mdi:speedometer"), + "mdi:speedometer", + category=DIAG), AlsavoProSensor(coordinator, None, "Device status code", "", 54, False, - "mdi:state-machine"), + "mdi:state-machine", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Heating max temperature", UnitOfTemperature.CELSIUS, 55, False, - "mdi:thermometer-high"), + "mdi:thermometer-high", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Cooling min temperature", UnitOfTemperature.CELSIUS, 56, False, - "mdi:thermometer-low"), + "mdi:thermometer-low", + category=DIAG), AlsavoProSensor(coordinator, None, "Manual frequency setting", "", 6, True, - "mdi:sine-wave"), + "mdi:sine-wave", + category=DIAG), AlsavoProSensor(coordinator, None, "Manual EEV setting", "", 7, True, - "mdi:valve"), + "mdi:valve", + category=DIAG), AlsavoProSensor(coordinator, None, "Manual fan speed setting", "", 8, True, - "mdi:fan"), + "mdi:fan", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Defrost in temperature", UnitOfTemperature.CELSIUS, 9, True, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Defrost out temperature", UnitOfTemperature.CELSIUS, 10, True, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, SensorDeviceClass.TEMPERATURE, "Water temperature calibration", UnitOfTemperature.CELSIUS, 11, True, - "mdi:thermometer"), + "mdi:thermometer", + category=DIAG), AlsavoProSensor(coordinator, None, "Defrost in time", "min", 12, True, - "mdi:timer"), + "mdi:timer", + category=DIAG), AlsavoProSensor(coordinator, None, "Defrost out time", "min", 13, True, - "mdi:timer"), + "mdi:timer", + category=DIAG), AlsavoProSensor(coordinator, None, "Hot over", @@ -306,7 +340,8 @@ async def async_setup_entry(hass, entry, async_add_entities): 14, True, "mdi:thermometer-high", - signed=True), + signed=True, + category=DIAG), AlsavoProSensor(coordinator, None, "Cold over", @@ -314,35 +349,40 @@ async def async_setup_entry(hass, entry, async_add_entities): 15, True, "mdi:thermometer-low", - signed=True), + signed=True, + category=DIAG), AlsavoProSensor(coordinator, None, "Unknown config 17", "", 17, True, - "mdi:help-circle"), + "mdi:help-circle", + category=DIAG), AlsavoProSensor(coordinator, None, "Current time", "", 32, True, - "mdi:clock"), + "mdi:clock", + category=DIAG), AlsavoProSensor(coordinator, None, "Timer on time", "", 33, True, - "mdi:timer"), + "mdi:timer", + category=DIAG), AlsavoProSensor(coordinator, None, "Timer off time", "", 34, True, - "mdi:timer"), + "mdi:timer", + category=DIAG), AlsavoProErrorSensor(coordinator, "Error messages"), ] @@ -357,13 +397,15 @@ def __init__(self, coordinator: AlsavoProDataCoordinator, idx: int, from_config: bool, icon: str, - signed: bool = False): + 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 diff --git a/custom_components/alsavopro/switch.py b/custom_components/alsavopro/switch.py index 450b3c5..d0546aa 100644 --- a/custom_components/alsavopro/switch.py +++ b/custom_components/alsavopro/switch.py @@ -3,6 +3,7 @@ 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 @@ -50,6 +51,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlsavoProSwitch(CoordinatorEntity, SwitchEntity): + _attr_entity_category = EntityCategory.CONFIG + def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoSwitchSpec): super().__init__(coordinator) self._coordinator = coordinator diff --git a/custom_components/alsavopro/time.py b/custom_components/alsavopro/time.py index 100db58..2b49d06 100644 --- a/custom_components/alsavopro/time.py +++ b/custom_components/alsavopro/time.py @@ -4,6 +4,7 @@ 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 @@ -45,6 +46,8 @@ async def async_setup_entry(hass, entry, async_add_entities): class AlsavoProTime(CoordinatorEntity, TimeEntity): + _attr_entity_category = EntityCategory.CONFIG + def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoTimeSpec): super().__init__(coordinator) self._coordinator = coordinator From 7d05e019104ea2a803566392549d6bf42a241603 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:20:50 +0200 Subject: [PATCH 40/41] Add binary sensors + group all entities under one device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports three ideas from the upstream goev fork, adapted to this fork's register layout and naming conventions: - binary_sensor.py with Frost protection / Connectivity / Alarm sensors. The frost-protection bit (PP07) is read from alarm register 50 bit 0x40 to match THIS firmware's layout — upstream reads register 49, which in our layout is "EE23: Compressor start failure". The connectivity sensor overrides available=True so it can report "off" when the pump drops offline. - AlsavoProEntity mixin in __init__.py providing DeviceInfo. Mixed into every entity class (sensor, climate, number, switch, time, binary sensor) so they all group under a single Alsavo Pro device card with manufacturer/model/serial and live HW/SW versions. Adding device_info doesn't change entity IDs or names, so existing dashboards and automations keep working. - AlsavoPro data handler gains serial_no, is_frost_protection, hardware_version and software_version accessors. Bumps version to 1.2.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++ README.md | 12 +++ custom_components/alsavopro/AlsavoPyCtrl.py | 24 +++++ custom_components/alsavopro/__init__.py | 24 ++++- custom_components/alsavopro/binary_sensor.py | 93 ++++++++++++++++++++ custom_components/alsavopro/climate.py | 4 +- custom_components/alsavopro/manifest.json | 2 +- custom_components/alsavopro/number.py | 4 +- custom_components/alsavopro/sensor.py | 6 +- custom_components/alsavopro/switch.py | 4 +- custom_components/alsavopro/time.py | 4 +- 11 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 custom_components/alsavopro/binary_sensor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a78d3e..7c3f2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 diff --git a/README.md b/README.md index 4bdd930..58aeac1 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,18 @@ Below ~-7 °C ambient the air-source COP collapses; no defrost setting can compe | 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 15391b7..c19b342 100644 --- a/custom_components/alsavopro/AlsavoPyCtrl.py +++ b/custom_components/alsavopro/AlsavoPyCtrl.py @@ -98,6 +98,10 @@ async def set_config(self, idx: int, value: int): def is_online(self) -> bool: return self._online + @property + def serial_no(self): + return self._serial_no + @property def unique_id(self): return f"{self._name}_{self._serial_no}" @@ -168,6 +172,26 @@ def errors(self): errors.append(description) return "\n".join(errors) + @property + def is_frost_protection(self): + """True when the pump's anti-freeze protection (PP07) is active. + + 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 hardware_version(self): + """Main board hardware revision (status register 65).""" + return self.get_status_value(65) + + @property + def software_version(self): + """Main board software revision (status register 66).""" + return self.get_status_value(66) + async def set_power_off(self): async with self._config4_lock: await self.set_config(4, self._data.get_config_value(4) & 0xFFDF) diff --git a/custom_components/alsavopro/__init__.py b/custom_components/alsavopro/__init__.py index 9af2107..d9b1e3d 100755 --- a/custom_components/alsavopro/__init__.py +++ b/custom_components/alsavopro/__init__.py @@ -4,6 +4,7 @@ from datetime import timedelta from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, @@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "climate", "number", "switch", "time"] +PLATFORMS = ["sensor", "binary_sensor", "climate", "number", "switch", "time"] # A single update() call performs the full UDP handshake + query. Allow generous # headroom so a momentarily-slow device doesn't get cancelled mid-handshake. @@ -116,3 +117,24 @@ async def _async_update_data(self): 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 b4882cd..55ea9af 100755 --- a/custom_components/alsavopro/climate.py +++ b/custom_components/alsavopro/climate.py @@ -15,7 +15,7 @@ CoordinatorEntity, ) -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .const import ( DOMAIN, POWER_MODE_MAP, @@ -51,7 +51,7 @@ 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 diff --git a/custom_components/alsavopro/manifest.json b/custom_components/alsavopro/manifest.json index de941ff..48f1df2 100755 --- a/custom_components/alsavopro/manifest.json +++ b/custom_components/alsavopro/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "codeowners": [], "config_flow": true, - "version": "1.1.0" + "version": "1.2.0" } diff --git a/custom_components/alsavopro/number.py b/custom_components/alsavopro/number.py index d5bbc25..bb135d8 100644 --- a/custom_components/alsavopro/number.py +++ b/custom_components/alsavopro/number.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .AlsavoPyCtrl import AlsavoPro from .const import DOMAIN @@ -100,7 +100,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(AlsavoProNumber(coordinator, spec) for spec in NUMBER_SPECS) -class AlsavoProNumber(CoordinatorEntity, NumberEntity): +class AlsavoProNumber(AlsavoProEntity, CoordinatorEntity, NumberEntity): _attr_mode = NumberMode.BOX _attr_entity_category = EntityCategory.CONFIG diff --git a/custom_components/alsavopro/sensor.py b/custom_components/alsavopro/sensor.py index 08b702c..4b5ed21 100644 --- a/custom_components/alsavopro/sensor.py +++ b/custom_components/alsavopro/sensor.py @@ -6,7 +6,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.helpers.entity import EntityCategory -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .const import ( DOMAIN ) @@ -389,7 +389,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class AlsavoProSensor(CoordinatorEntity, SensorEntity): +class AlsavoProSensor(AlsavoProEntity, CoordinatorEntity, SensorEntity): def __init__(self, coordinator: AlsavoProDataCoordinator, device_class: SensorDeviceClass, name: str, @@ -447,7 +447,7 @@ 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) diff --git a/custom_components/alsavopro/switch.py b/custom_components/alsavopro/switch.py index d0546aa..db781f4 100644 --- a/custom_components/alsavopro/switch.py +++ b/custom_components/alsavopro/switch.py @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .AlsavoPyCtrl import AlsavoPro from .const import DOMAIN @@ -50,7 +50,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(AlsavoProSwitch(coordinator, spec) for spec in SWITCH_SPECS) -class AlsavoProSwitch(CoordinatorEntity, SwitchEntity): +class AlsavoProSwitch(AlsavoProEntity, CoordinatorEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoSwitchSpec): diff --git a/custom_components/alsavopro/time.py b/custom_components/alsavopro/time.py index 2b49d06..0c0530a 100644 --- a/custom_components/alsavopro/time.py +++ b/custom_components/alsavopro/time.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AlsavoProDataCoordinator +from . import AlsavoProDataCoordinator, AlsavoProEntity from .AlsavoPyCtrl import AlsavoPro from .const import DOMAIN @@ -45,7 +45,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(AlsavoProTime(coordinator, spec) for spec in TIME_SPECS) -class AlsavoProTime(CoordinatorEntity, TimeEntity): +class AlsavoProTime(AlsavoProEntity, CoordinatorEntity, TimeEntity): _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: AlsavoProDataCoordinator, spec: AlsavoTimeSpec): From 2e52ee1f2c5b02890b477e92b6df01794667f379 Mon Sep 17 00:00:00 2001 From: laurensdehoorne <55842703+laurensdehoorne@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:29:37 +0200 Subject: [PATCH 41/41] docs: add fork comparison table to README Document why this fork diverges from upstream goev: 10 writable settings vs 2, switch/time platforms, persistent read session, device-type-aware HVAC modes, signed Cold over, frost-protection from the correct alarm register, and the robustness fixes. Includes an honest note on the entity-naming tradeoff. Co-Authored-By: Claude Opus 4.8 --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 58aeac1..28cd4e5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,28 @@ Custom component for controlling pool heatpumps that uses the Alsavo Pro app in Home Assistant. +## Why this fork + +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 In Home Assistant, create a folder under *custom_components* named *AlsavoPro* and copy all the content of this project to that folder.