├── README.md ├── custom_components └── alsavopro │ ├── .idea │ ├── .gitignore │ └── AlsavoPro.iml │ ├── AlsavoPyCtrl.py │ ├── __init__.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ ├── translations │ └── en.json │ └── udpclient.py └── hacs.json /README.md: -------------------------------------------------------------------------------- 1 | # Alsavo Pro / Swim & Fun / Artic Pro / Zealux ++ pool heatpump 2 | 3 | Custom component for controlling pool heatpumps that uses the Alsavo Pro app in Home Assistant. 4 | 5 | **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! 6 | 7 | If some adult with the proper knowledge could improve this, and maybe make it installable with HACS, please feel free to do so! 8 | 9 | ## Install 10 | #### Manually 11 | In Home Assistant, create a folder under *custom_components* named *AlsavoPro* and copy all the content of this project to that folder. 12 | Restart Home Assistant and go to *Devices and Services* and press *+Add integration*. 13 | Search for *AlsavoPro* and add it. 14 | #### HACS Custom Repository 15 | In HACS, add a custom repository and use https://github.com/goev/AlsavoProHomeAssistantIntegration 16 | Download from HACS. 17 | Restart Home Assistant and go to *Devices and Services* and press *+Add integration*. 18 | Search for *AlsavoPro* and add it. 19 | 20 | ## Configuration 21 | 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. 22 | Password is the same as the one you logged into the Alsavo Pro app with. 23 | 24 | Ip-address and port can be one of two: 25 | - If you want to use the cloud, set IP-address to 47.254.157.150 and port to 51192. 26 | - If you want to bypass the cloud, enter the heat pumps ip-address and use port 1194. 27 | 28 | ## AlsavoCtrl 29 | This code is very much based on AlsavoCtrl: https://github.com/strandborg/AlsavoCtrl 30 | -------------------------------------------------------------------------------- /custom_components/alsavopro/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /custom_components/alsavopro/.idea/AlsavoPro.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /custom_components/alsavopro/AlsavoPyCtrl.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import random 4 | import struct 5 | from datetime import datetime, timezone 6 | from enum import Enum 7 | from custom_components.alsavopro.const import MODE_TO_CONFIG, NO_WATER_FLUX, WATER_TEMP_TOO_LOW, MAX_UPDATE_RETRIES, \ 8 | MAX_SET_CONFIG_RETRIES 9 | from .udpclient import UDPClient 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class AlsavoPro: 15 | """Alsavo Pro data handler.""" 16 | 17 | def __init__(self, name, serial_no, ip_address, port_no, password): 18 | """Init Alsavo Pro data handler.""" 19 | self._name = name 20 | self._serial_no = serial_no 21 | self._ip_address = ip_address 22 | self._port_no = port_no 23 | self._password = password 24 | self._data = QueryResponse(0, 0) 25 | self._session = AlsavoSocketCom() 26 | self._set_retries = 0 27 | self._update_retries = 0 28 | self._online = False 29 | 30 | async def update(self): 31 | _LOGGER.debug(f"update") 32 | try: 33 | await self._session.connect(self._ip_address, int(self._port_no), int(self._serial_no), self._password) 34 | data = await self._session.query_all() 35 | if data is not None: 36 | self._data = data 37 | except Exception as e: 38 | if self._update_retries < MAX_UPDATE_RETRIES: 39 | self._update_retries += 1 40 | await self.update() 41 | self._online = True 42 | else: 43 | self._update_retries = 0 44 | _LOGGER.error(f"Unable to update: {e}") 45 | self._online = False 46 | 47 | async def set_config(self, idx: int, value: int): 48 | _LOGGER.debug(f"set_config({idx}, {value})") 49 | try: 50 | await self._session.connect(self._ip_address, int(self._port_no), int(self._serial_no), self._password) 51 | await self._session.set_config(idx, value) 52 | except Exception as e: 53 | if self._set_retries < MAX_SET_CONFIG_RETRIES: 54 | self._set_retries += 1 55 | await self.set_config(idx, value) 56 | self._online = True 57 | else: 58 | self._set_retries = 0 59 | _LOGGER.error(f"Unable to set config: {idx}, {value} Error: {e}") 60 | self._online = False 61 | 62 | @property 63 | def is_online(self) -> bool: 64 | return self._data.parts > 0 65 | 66 | @property 67 | def unique_id(self): 68 | return f"{self._name}_{self._serial_no}" 69 | 70 | @property 71 | def target_temperature(self): 72 | return self.get_temperature_from_config(MODE_TO_CONFIG.get(self.operating_mode, 0)) 73 | 74 | async def set_target_temperature(self, value: float): 75 | config_key = MODE_TO_CONFIG.get(self.operating_mode) 76 | if config_key is not None: 77 | await self.set_config(config_key, int(value * 10)) 78 | 79 | def get_status_value(self, idx: int): 80 | return self._data.get_status_value(idx) 81 | 82 | def get_config_value(self, idx: int): 83 | return self._data.get_config_value(idx) 84 | 85 | def get_temperature_from_status(self, idx): 86 | return self._data.get_status_temperature_value(idx) 87 | 88 | def get_temperature_from_config(self, idx): 89 | return self._data.get_config_temperature_value(idx) 90 | 91 | @property 92 | def water_in_temperature(self): 93 | return self.get_temperature_from_status(16) 94 | 95 | @property 96 | def water_out_temperature(self): 97 | return self.get_temperature_from_status(17) 98 | 99 | @property 100 | def ambient_temperature(self): 101 | return self.get_temperature_from_status(18) 102 | 103 | @property 104 | def operating_mode(self): 105 | return self._data.get_config_value(4) & 3 106 | 107 | @property 108 | def is_timer_on_enabled(self): 109 | return self._data.get_config_value(4) & 4 == 4 110 | 111 | @property 112 | def water_pump_running_mode(self): 113 | return self._data.get_config_value(4) & 8 == 8 114 | 115 | @property 116 | def electronic_valve_style(self): 117 | return self._data.get_config_value(4) & 16 == 16 118 | 119 | @property 120 | def is_power_on(self): 121 | return self._data.get_config_value(4) & 32 == 32 122 | 123 | @property 124 | def power_mode(self): 125 | return self._data.get_config_value(16) 126 | 127 | @property 128 | def is_debug_mode(self): 129 | return self._data.get_config_value(4) & 64 == 64 130 | 131 | @property 132 | def is_timer_off_enabled(self): 133 | return self._data.get_config_value(4) & 128 == 128 134 | 135 | @property 136 | def manual_defrost(self): 137 | return self._data.get_config_value(5) & 1 == 1 138 | 139 | @property 140 | def errors(self): 141 | error = "" 142 | if self.get_status_value(48) & 0x4 == 0x4: 143 | error += NO_WATER_FLUX 144 | if self.get_status_value(49) & 0x400 == 0x400: 145 | error += WATER_TEMP_TOO_LOW 146 | return error 147 | 148 | async def set_power_off(self): 149 | await self.set_config(4, self._data.get_config_value(4) & 0xFFDF) 150 | 151 | async def set_cooling_mode(self): 152 | await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 32) 153 | 154 | async def set_heating_mode(self): 155 | await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 33) 156 | 157 | async def set_auto_mode(self): 158 | await self.set_config(4, (self._data.get_config_value(4) & 0xFFDC) + 34) 159 | 160 | async def set_power_mode(self, value: int): 161 | await self.set_config(16, value) 162 | 163 | @property 164 | def name(self): 165 | return self._name 166 | 167 | 168 | class PacketHeader: 169 | """ This is the packet header """ 170 | """ It consists of 16 bytes and have the following attributes: """ 171 | """ - hdr - byte - 0x32 = request, 0x30 = response """ 172 | """ - pad - byte - Padding. Always 0 """ 173 | """ - seq - Int16 - Sequence number (monotonically increasing once session has been set up, otherwise 0) """ 174 | """ - csid - Int32 - ??? """ 175 | """ - dsid - Int32 - ??? """ 176 | """ - cmd - Int16 - Command """ 177 | """ - Payload length - Int16 - """ 178 | 179 | def __init__(self, hdr, seq, csid, dsid, cmd, payload_length): 180 | self.hdr = hdr 181 | self.pad = 0 182 | self.seq = seq 183 | self.csid = csid 184 | self.dsid = dsid 185 | self.cmd = cmd 186 | self.payloadLength = payload_length 187 | 188 | @property 189 | def is_reply(self): 190 | return (self.hdr & 2) == 0 191 | 192 | def pack(self): 193 | # Struct format: char, char, uint16, uint32, uint32, uint16, uint16 194 | return struct.pack('!BBHIIHH', self.hdr, self.pad, self.seq, self.csid, self.dsid, self.cmd, self.payloadLength) 195 | 196 | @staticmethod 197 | def unpack(data): 198 | unpacked_data = struct.unpack('!BBHIIHH', data) 199 | return PacketHeader(unpacked_data[0], unpacked_data[2], unpacked_data[3], unpacked_data[4], unpacked_data[5], 200 | unpacked_data[6]) 201 | 202 | 203 | class Timestamp: 204 | def __init__(self): 205 | current_time = datetime.now(timezone.utc) 206 | self.year = current_time.year 207 | self.month = current_time.month 208 | self.day = current_time.day 209 | self.hour = current_time.hour 210 | self.min = current_time.minute 211 | self.sec = current_time.second 212 | self.tz = 2 # Placeholder 213 | 214 | def pack(self): 215 | # Struct format: uint16, char, char, char, char, char, char 216 | return struct.pack('!HBBBBBB', self.year, self.month, self.day, self.hour, self.min, self.sec, self.tz) 217 | 218 | 219 | class AuthIntro: 220 | def __init__(self, client_token, serial_inv): 221 | self.hdr = PacketHeader(0x32, 0, 0, 0, 0xf2, 0x28) 222 | self.act1, self.act2, self.act3, self.act4 = 1, 1, 2, 0 223 | self.clientToken = client_token 224 | self.pumpSerial = serial_inv 225 | self._uuid = [0x97e8ced0, 0xf83640bc, 0xb4dd57e3, 0x22adc3a0] 226 | self.timestamp = Timestamp() 227 | 228 | def pack(self): 229 | packed_hdr = self.hdr.pack() 230 | packed_uuid = struct.pack('!IIII', *self._uuid) 231 | packed_data = struct.pack('!BBBBIQ', self.act1, self.act2, self.act3, self.act4, self.clientToken, 232 | self.pumpSerial) + packed_uuid + self.timestamp.pack() 233 | return packed_hdr + packed_data 234 | 235 | 236 | class AuthChallenge: 237 | def __init__(self, hdr, act1, act2, act3, act4, server_token): 238 | self.hdr = hdr 239 | self.act1 = act1 240 | self.act2 = act2 241 | self.act3 = act3 242 | self.act4 = act4 243 | self.serverToken = server_token 244 | 245 | @staticmethod 246 | def unpack(data): 247 | # 16 first bytes are header 248 | packet_hdr = PacketHeader.unpack(data[0:16]) 249 | 250 | # Define the format string for unpacking 251 | format_string = '!BBBBI' # Adjust to match your structure 252 | 253 | # Unpack the serialized data 254 | unpacked_data = struct.unpack(format_string, data[16:24]) 255 | 256 | # Create a new instance of the class and initialize its attributes 257 | obj = AuthChallenge(packet_hdr, unpacked_data[0], unpacked_data[1], unpacked_data[2], unpacked_data[3], 258 | unpacked_data[4]) 259 | 260 | return obj 261 | 262 | @property 263 | def is_authorized(self): 264 | return self.act1 == 3 and self.act2 == 0 and self.act3 == 0 and self.act4 == 0 265 | 266 | 267 | class AuthResponse: 268 | def __init__(self, csid, dsid, resp): 269 | # Header fields 270 | self.hdr = PacketHeader(0x32, 0, csid, dsid, 0xf2, 0x1c) 271 | self.act1, self.act2, self.act3, self.act4 = 4, 0, 0, 3 272 | self.timestamp = Timestamp() 273 | 274 | # Response field (as a bytes object) 275 | self.response = bytes(resp) 276 | 277 | def pack(self): 278 | packed_data = struct.pack('!BBBB', self.act1, self.act2, self.act3, self.act4) 279 | return self.hdr.pack() + packed_data + self.response + self.timestamp.pack() 280 | 281 | 282 | class Payload: 283 | """ Config, Status or device info-payload packet """ 284 | """ Is part of the QueryResponse packet """ 285 | def __init__(self, data_type, sub_type, size, start_idx, indices): 286 | self.type = data_type 287 | self.subType = sub_type 288 | self.size = size 289 | self.startIdx = start_idx 290 | self.indices = indices 291 | self.data = [] 292 | 293 | def get_value(self, idx): 294 | if idx - self.startIdx < 0 or idx - self.startIdx > self.data.__len__(): 295 | return 0 296 | return self.data[idx - self.startIdx] 297 | 298 | @staticmethod 299 | def unpack(data): 300 | unpacked_data = struct.unpack('!IHHHH', data[0:12]) 301 | obj = Payload(unpacked_data[0], unpacked_data[1], unpacked_data[2], unpacked_data[3], unpacked_data[4]) 302 | if obj.subType == 1 or obj.subType == 2: 303 | obj.data = struct.unpack('>' + 'H' * (obj.size // 2), data[12:12 + obj.size]) 304 | else: 305 | obj.startIdx = 0 306 | obj.indices = 0 307 | obj.data = struct.unpack('>' + 'H' * (obj.size // 2), data[8:8 + obj.size]) 308 | return obj 309 | 310 | 311 | class QueryResponse: 312 | """ Query response containing data payload from heatpump. """ 313 | """ Contains both status and config. """ 314 | 315 | def __init__(self, action, parts): 316 | self.action = action 317 | self.parts = parts 318 | self.__payloads = [] 319 | self.__status = None 320 | self.__config = None 321 | self.__deviceInfo = None 322 | 323 | def get_status_value(self, idx: int): 324 | if self.__status is None: 325 | return 0 326 | else: 327 | return self.__status.get_value(idx) 328 | 329 | def get_config_value(self, idx: int): 330 | if self.__config is None: 331 | return 0 332 | else: 333 | return self.__config.get_value(idx) 334 | 335 | def get_signed_status_value(self, idx: int): 336 | unsigned_int = self.get_status_value(idx) 337 | if unsigned_int > 32767: 338 | return unsigned_int - 65536 339 | else: 340 | return unsigned_int 341 | 342 | def get_signed_config_value(self, idx: int): 343 | unsigned_int = self.get_config_value(idx) 344 | if unsigned_int > 32767: 345 | return unsigned_int - 65536 346 | else: 347 | return unsigned_int 348 | 349 | def get_status_temperature_value(self, idx: int): 350 | return self.get_signed_status_value(idx) / 10 351 | 352 | def get_config_temperature_value(self, idx: int): 353 | return self.get_signed_config_value(idx) / 10 354 | 355 | @staticmethod 356 | def unpack(data): 357 | unpacked_data = struct.unpack('!BBH', data[0:4]) 358 | obj = QueryResponse(unpacked_data[0], unpacked_data[1]) 359 | idx = 4 360 | 361 | while idx < data.__len__(): 362 | payload = Payload.unpack(data[idx:]) 363 | if payload.subType == 1: 364 | obj.__status = payload 365 | elif payload.subType == 2: 366 | obj.__config = payload 367 | if payload.subType == 3: 368 | obj.__deviceInfo = payload 369 | obj.__payloads.append(payload) 370 | idx += payload.size + 8 371 | 372 | return obj 373 | 374 | 375 | def md5_hash(text): 376 | """ Simple hashing of password """ 377 | md5 = hashlib.md5() 378 | md5.update(text.encode()) 379 | return md5.digest() 380 | 381 | 382 | class ConnectionStatus(Enum): 383 | Disconnected = 0 384 | Connected = 1 385 | 386 | 387 | class AlsavoSocketCom: 388 | """ Socket communication handler for the Alsavo Pro integration """ 389 | """ Everything is pull-based. """ 390 | 391 | def __init__(self): 392 | self.serverToken = None 393 | self.DSIS = None 394 | self.CSID = None 395 | self.password = None 396 | self.serialQ = None 397 | self.clientToken = None 398 | self.lstConfigReqTime = None 399 | self.client = None 400 | 401 | async def send_and_receive(self, bytes_to_send): 402 | _LOGGER.debug(f"send_and_receive())") 403 | response = await self.client.send_rcv(bytes_to_send) 404 | _LOGGER.debug(f"Received response") 405 | return response 406 | 407 | async def send(self, bytes_to_send): 408 | _LOGGER.debug(f"send())") 409 | await self.client.send(bytes_to_send) 410 | 411 | async def get_auth_challenge(self): 412 | auth_intro = AuthIntro(self.clientToken, self.serialQ) 413 | response = await self.send_and_receive(bytes(auth_intro.pack())) 414 | return AuthChallenge.unpack(response[0]) 415 | 416 | async def send_auth_response(self, ctx): 417 | resp = AuthResponse(self.CSID, self.DSIS, ctx.digest()) 418 | return await self.send_and_receive(resp.pack()) 419 | 420 | async def send_and_rcv_packet(self, payload: bytes, cmd=0xf4): 421 | _LOGGER.debug(f"send_and_rcv_packet(payload, {cmd})") 422 | if self.CSID is not None and self.DSIS is not None: 423 | return await self.send_and_receive( 424 | PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, payload.__len__()).pack() + payload 425 | ) 426 | return None 427 | 428 | async def send_packet(self, payload: bytes, cmd=0xf4): 429 | _LOGGER.debug(f"send_packet(payload, {cmd})") 430 | if self.CSID is not None and self.DSIS is not None: 431 | await self.send(PacketHeader(0x32, 0, self.CSID, self.DSIS, cmd, payload.__len__()).pack() + payload) 432 | 433 | async def query_all(self): 434 | """ Query all information from the heat pump """ 435 | _LOGGER.debug("socket.query_all") 436 | resp = await self.send_and_rcv_packet(b'\x08\x01\x00\x00\x00\x02\x00\x2e\xff\xff\x00\x00') 437 | self.lstConfigReqTime = datetime.now() 438 | if resp is None: 439 | raise Exception("query_all: no response") 440 | return QueryResponse.unpack(resp[0][16:]) 441 | 442 | async def set_config(self, idx: int, value: int): 443 | """ Set configuration values on the heat pump """ 444 | _LOGGER.debug(f"socket.set_config({idx}, {value})") 445 | idx_h = ((idx >> 8) & 0xff).to_bytes(1, 'big') 446 | idx_l = (idx & 0xff).to_bytes(1, 'big') 447 | val_h = ((value >> 8) & 0xff).to_bytes(1, 'big') 448 | val_l = (value & 0xff).to_bytes(1, 'big') 449 | 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) 450 | 451 | async def connect(self, server_ip, server_port, serial, password): 452 | _LOGGER.debug("Connecting to Alsavo Pro") 453 | 454 | self.clientToken = random.randint(0, 65535) 455 | self.serialQ = serial 456 | self.password = password 457 | self.client = UDPClient(server_ip, server_port) 458 | 459 | _LOGGER.debug("Asking for auth challenge") 460 | auth_challenge = await self.get_auth_challenge() 461 | 462 | if not auth_challenge.is_authorized: 463 | raise ConnectionError("Invalid auth challenge packet (pump offline?), disconnecting") 464 | 465 | self.CSID = auth_challenge.hdr.csid 466 | self.DSIS = auth_challenge.hdr.dsid 467 | self.serverToken = auth_challenge.serverToken 468 | 469 | _LOGGER.debug(f"Received handshake, CSID={hex(self.CSID)}, DSID={hex(self.DSIS)}, " 470 | f"server token {hex(self.serverToken)}") 471 | 472 | ctx = hashlib.md5() 473 | ctx.update(self.clientToken.to_bytes(4, "big")) 474 | ctx.update(self.serverToken.to_bytes(4, "big")) 475 | ctx.update(md5_hash(self.password)) 476 | 477 | response = await self.send_auth_response(ctx) 478 | 479 | if response is None or response[0].__len__() == 0: 480 | raise ConnectionError("Server not responding to auth response, disconnecting.") 481 | 482 | act = int.from_bytes(response[0][16:20], byteorder='little') 483 | if act != 0x00000005: 484 | raise ConnectionError("Server returned error in auth, disconnecting") 485 | 486 | _LOGGER.debug("Connected.") 487 | -------------------------------------------------------------------------------- /custom_components/alsavopro/__init__.py: -------------------------------------------------------------------------------- 1 | """Alsavo Pro pool heat pump integration.""" 2 | import logging 3 | from datetime import timedelta 4 | 5 | import async_timeout 6 | from homeassistant.helpers.update_coordinator import ( 7 | DataUpdateCoordinator, 8 | ) 9 | 10 | from homeassistant.const import ( 11 | CONF_PASSWORD, 12 | CONF_IP_ADDRESS, 13 | CONF_PORT, 14 | CONF_NAME, 15 | ) 16 | 17 | from .AlsavoPyCtrl import AlsavoPro 18 | from .const import ( 19 | DOMAIN, 20 | SERIAL_NO, 21 | ) 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | async def async_setup(hass, config): 27 | return True 28 | 29 | 30 | async def async_setup_entry(hass, entry): 31 | """Set up the Alsavo Pro heater.""" 32 | name = entry.data.get(CONF_NAME) 33 | serial_no = entry.data.get(SERIAL_NO) 34 | ip_address = entry.data.get(CONF_IP_ADDRESS) 35 | port_no = entry.data.get(CONF_PORT) 36 | password = entry.data.get(CONF_PASSWORD) 37 | 38 | data_handler = AlsavoPro(name, serial_no, ip_address, port_no, password) 39 | await data_handler.update() 40 | data_coordinator = AlsavoProDataCoordinator(hass, data_handler) 41 | 42 | if DOMAIN not in hass.data: 43 | hass.data[DOMAIN] = {} 44 | hass.data[DOMAIN][entry.entry_id] = data_coordinator 45 | 46 | for platform in ('sensor', 'climate'): 47 | hass.async_create_task( 48 | hass.config_entries.async_forward_entry_setup(entry, platform) 49 | ) 50 | 51 | return True 52 | 53 | 54 | async def async_unload_entry(hass, config_entry): 55 | """Unload a config entry.""" 56 | unload_ok = await hass.config_entries.async_forward_entry_unload( 57 | config_entry, "climate" 58 | ) 59 | unload_ok |= await hass.config_entries.async_forward_entry_unload( 60 | config_entry, "sensor" 61 | ) 62 | return unload_ok 63 | 64 | 65 | class AlsavoProDataCoordinator(DataUpdateCoordinator): 66 | def __init__(self, hass, data_handler): 67 | """Initialize my coordinator.""" 68 | super().__init__( 69 | hass, 70 | _LOGGER, 71 | # Name of the data. For logging purposes. 72 | name="AlsavoPro", 73 | # Polling interval. Will only be polled if there are subscribers. 74 | update_interval=timedelta(seconds=15), 75 | ) 76 | self.data_handler = data_handler 77 | 78 | async def _async_update_data(self): 79 | _LOGGER.debug("_async_update_data") 80 | try: 81 | async with async_timeout.timeout(10): 82 | await self.data_handler.update() 83 | return self.data_handler 84 | except Exception as ex: 85 | _LOGGER.debug("_async_update_data timed out") 86 | -------------------------------------------------------------------------------- /custom_components/alsavopro/climate.py: -------------------------------------------------------------------------------- 1 | """Support for Alsavo Pro wifi-enabled pool heaters.""" 2 | import logging 3 | 4 | from homeassistant.components.climate import ( 5 | PLATFORM_SCHEMA, 6 | ClimateEntity, 7 | ClimateEntityFeature, 8 | HVACMode 9 | ) 10 | 11 | from homeassistant.const import ( 12 | ATTR_TEMPERATURE, 13 | CONF_PASSWORD, 14 | CONF_IP_ADDRESS, 15 | CONF_PORT, 16 | CONF_NAME, 17 | PRECISION_TENTHS, 18 | UnitOfTemperature, 19 | ) 20 | 21 | from homeassistant.helpers.update_coordinator import ( 22 | CoordinatorEntity, 23 | DataUpdateCoordinator, 24 | UpdateFailed, 25 | ) 26 | 27 | from . import AlsavoProDataCoordinator 28 | from .const import ( 29 | DOMAIN, 30 | POWER_MODE_MAP 31 | ) 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | 36 | async def async_setup_entry(hass, entry, async_add_entities): 37 | async_add_entities([AlsavoProClimate(hass.data[DOMAIN][entry.entry_id])]) 38 | 39 | 40 | class AlsavoProClimate(CoordinatorEntity, ClimateEntity): 41 | """ Climate platform for Alsavo Pro pool heater """ 42 | 43 | def __init__(self, coordinator: AlsavoProDataCoordinator): 44 | """Initialize the heater.""" 45 | super().__init__(coordinator) 46 | self.coordinator = coordinator 47 | self._data_handler = self.coordinator.data_handler 48 | self._name = self._data_handler.name 49 | 50 | @property 51 | def supported_features(self): 52 | """Return the list of supported features.""" 53 | return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE 54 | 55 | @property 56 | def unique_id(self): 57 | """Return a unique ID.""" 58 | return self._data_handler.unique_id 59 | 60 | @property 61 | def name(self): 62 | """Return the name of the device, if any.""" 63 | return self._name 64 | 65 | @property 66 | def available(self) -> bool: 67 | """Return True if roller and hub is available.""" 68 | return self._data_handler.is_online 69 | 70 | @property 71 | def hvac_mode(self): 72 | """Return hvac operation i.e. heat, cool mode.""" 73 | operating_mode_map = { 74 | 0: HVACMode.COOL, 75 | 1: HVACMode.HEAT, 76 | 2: HVACMode.AUTO 77 | } 78 | 79 | if not self._data_handler.is_power_on: 80 | return HVACMode.OFF 81 | 82 | return operating_mode_map.get(self._data_handler.operating_mode) 83 | 84 | @property 85 | def preset_mode(self): 86 | """Return Preset modes silent, smart mode.""" 87 | return POWER_MODE_MAP.get(self._data_handler.power_mode) 88 | 89 | @property 90 | def icon(self): 91 | """Return nice icon for heater.""" 92 | hvac_mode_icons = { 93 | HVACMode.HEAT: "mdi:fire", 94 | HVACMode.COOL: "mdi:snowflake", 95 | HVACMode.AUTO: "mdi:refresh-auto" 96 | } 97 | 98 | return hvac_mode_icons.get(self.hvac_mode, "mdi:hvac-off") 99 | 100 | @property 101 | def hvac_modes(self): 102 | """Return the list of available hvac operation modes.""" 103 | return [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF] 104 | 105 | @property 106 | def preset_modes(self): 107 | """Return the list of available hvac operation modes.""" 108 | return ['Silent', 'Smart', 'Powerful'] 109 | 110 | async def async_set_hvac_mode(self, hvac_mode): 111 | """Set hvac mode.""" 112 | hvac_mode_actions = { 113 | HVACMode.OFF: self._data_handler.set_power_off, 114 | HVACMode.COOL: self._data_handler.set_cooling_mode, 115 | HVACMode.HEAT: self._data_handler.set_heating_mode, 116 | HVACMode.AUTO: self._data_handler.set_auto_mode 117 | } 118 | 119 | action = hvac_mode_actions.get(hvac_mode) 120 | if action: 121 | await action() 122 | await self.coordinator.async_request_refresh() 123 | 124 | async def async_set_preset_mode(self, preset_mode): 125 | """Set hvac preset mode.""" 126 | preset_mode_to_power_mode = { 127 | 'Silent': 0, # Silent 128 | 'Smart': 1, # Smart 129 | 'Powerful': 2 # Powerful 130 | } 131 | 132 | power_mode = preset_mode_to_power_mode.get(preset_mode) 133 | if power_mode is not None: 134 | await self._data_handler.set_power_mode(power_mode) 135 | await self.coordinator.async_request_refresh() 136 | 137 | @property 138 | def temperature_unit(self): 139 | """Return the unit of measurement which this device uses.""" 140 | return UnitOfTemperature.CELSIUS 141 | 142 | @property 143 | def min_temp(self): 144 | """Return the minimum temperature.""" 145 | return self._data_handler.get_temperature_from_status(56) 146 | 147 | @property 148 | def max_temp(self): 149 | """Return the maximum temperature.""" 150 | return self._data_handler.get_temperature_from_status(55) 151 | 152 | @property 153 | def current_temperature(self): 154 | """Return the current temperature.""" 155 | return self._data_handler.water_in_temperature 156 | 157 | @property 158 | def target_temperature(self): 159 | """Return the temperature we try to reach.""" 160 | return self._data_handler.target_temperature 161 | 162 | @property 163 | def target_temperature_step(self): 164 | """Return the supported step of target temperature.""" 165 | return PRECISION_TENTHS 166 | 167 | async def async_set_temperature(self, **kwargs): 168 | """Set new target temperature.""" 169 | temperature = kwargs.get(ATTR_TEMPERATURE) 170 | if temperature is None: 171 | return 172 | await self._data_handler.set_target_temperature(temperature) 173 | await self.coordinator.async_request_refresh() 174 | 175 | async def async_update(self): 176 | """Get the latest data.""" 177 | self._data_handler = self.coordinator.data_handler 178 | -------------------------------------------------------------------------------- /custom_components/alsavopro/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for AlsavoPro pool heater integration.""" 2 | import voluptuous as vol 3 | from homeassistant import config_entries, core, exceptions 4 | from homeassistant.core import callback 5 | from homeassistant.const import ( 6 | CONF_PASSWORD, 7 | CONF_NAME, 8 | CONF_IP_ADDRESS, 9 | CONF_PORT 10 | ) 11 | 12 | from .const import ( 13 | SERIAL_NO, 14 | DOMAIN 15 | ) 16 | 17 | # _LOGGER = logging.getLogger(__name__) 18 | 19 | DATA_SCHEMA = vol.Schema( 20 | { 21 | vol.Required(CONF_NAME): str, 22 | vol.Required(SERIAL_NO): str, 23 | vol.Required(CONF_IP_ADDRESS): str, 24 | vol.Required(CONF_PORT): str, 25 | vol.Required(CONF_PASSWORD): str, 26 | } 27 | ) 28 | 29 | 30 | async def validate_input(hass: core.HomeAssistant, name, serial_no, ip_address, port_no, password): 31 | """Validate the user input allows us to connect.""" 32 | 33 | # Pre-validation for missing mandatory fields 34 | if not name: 35 | raise MissingNameValue("The 'name' field is required.") 36 | if not password: 37 | raise MissingPasswordValue("The 'password' field is required.") 38 | 39 | for entry in hass.config_entries.async_entries(DOMAIN): 40 | if any([ 41 | entry.data[SERIAL_NO] == serial_no, 42 | entry.data[CONF_NAME] == name, 43 | entry.data[CONF_IP_ADDRESS] == ip_address, 44 | entry.data[CONF_PORT] == port_no 45 | ]): 46 | raise AlreadyConfigured("An entry with the given details already exists.") 47 | 48 | # Additional validations (if any) go here... 49 | 50 | 51 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 52 | """Handle a config flow for Alsavo Pro pool heater integration.""" 53 | 54 | VERSION = 1 55 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 56 | 57 | async def async_step_user(self, user_input=None): 58 | """Handle the initial step.""" 59 | errors = {} 60 | 61 | if user_input is not None: 62 | try: 63 | name = user_input[CONF_NAME] 64 | serial_no = user_input[SERIAL_NO] 65 | ip_address = user_input[CONF_IP_ADDRESS] 66 | port_no = user_input[CONF_PORT] 67 | password = user_input[CONF_PASSWORD].replace(" ", "") 68 | await validate_input(self.hass, name, serial_no, ip_address, port_no, password) 69 | unique_id = f"{name}-{serial_no}" 70 | await self.async_set_unique_id(unique_id) 71 | self._abort_if_unique_id_configured() 72 | 73 | return self.async_create_entry( 74 | title=unique_id, 75 | data={CONF_NAME: name, 76 | SERIAL_NO: serial_no, 77 | CONF_IP_ADDRESS: ip_address, 78 | CONF_PORT: port_no, 79 | CONF_PASSWORD: password}, 80 | ) 81 | 82 | except AlreadyConfigured: 83 | return self.async_abort(reason="already_configured") 84 | except CannotConnect: 85 | errors["base"] = "connection_error" 86 | except MissingNameValue: 87 | errors["base"] = "missing_name" 88 | 89 | return self.async_show_form( 90 | step_id="user", 91 | data_schema=DATA_SCHEMA, 92 | errors=errors, 93 | ) 94 | 95 | 96 | class OptionsFlowHandler(config_entries.OptionsFlow): 97 | async def async_step_init(self, user_input=None): 98 | return self.async_show_form( 99 | step_id="init", 100 | data_schema=vol.Schema({ 101 | vol.Optional(CONF_PASSWORD): str, 102 | }), 103 | ) 104 | 105 | 106 | @callback 107 | def async_get_options_flow(config_entry): 108 | return OptionsFlowHandler(config_entry) 109 | 110 | 111 | class CannotConnect(exceptions.HomeAssistantError): 112 | """Error to indicate we cannot connect.""" 113 | 114 | 115 | class AlreadyConfigured(exceptions.HomeAssistantError): 116 | """Error to indicate host is already configured.""" 117 | 118 | 119 | class MissingNameValue(exceptions.HomeAssistantError): 120 | """Error to indicate name is missing.""" 121 | 122 | 123 | class MissingPasswordValue(exceptions.HomeAssistantError): 124 | """Error to indicate name is missing.""" 125 | -------------------------------------------------------------------------------- /custom_components/alsavopro/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Alsavo Pro pool heater integration.""" 2 | 3 | SERIAL_NO = "serial_no" 4 | DOMAIN = "alsavopro" 5 | 6 | POWER_MODE_MAP = { 7 | 0: 'Silent', 8 | 1: 'Smart', 9 | 2: 'Powerful' 10 | } 11 | # Static mapping of operating modes to config keys 12 | MODE_TO_CONFIG = {0: 2, # Cool 13 | 1: 1, # Heat 14 | 2: 3} # Auto 15 | 16 | # Errors 17 | NO_WATER_FLUX = "No water flux or water flow switch failure.\n\r" 18 | WATER_TEMP_TOO_LOW = "Water temperature (T2) too low protection under cooling mode.\n\r" 19 | 20 | # Max retries 21 | MAX_UPDATE_RETRIES = 10 22 | MAX_SET_CONFIG_RETRIES = 10 23 | -------------------------------------------------------------------------------- /custom_components/alsavopro/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "alsavopro", 3 | "name": "AlsavoPro", 4 | "documentation": "https://github.com/goev/AlsavoProHomeAssistantIntegration", 5 | "requirements": [], 6 | "codeowners": [], 7 | "config_flow": true, 8 | "version": "0.0.1" 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/alsavopro/sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import ( 2 | SensorEntity, 3 | SensorDeviceClass 4 | ) 5 | 6 | from . import AlsavoProDataCoordinator 7 | from .const import ( 8 | DOMAIN 9 | ) 10 | 11 | from homeassistant.helpers.update_coordinator import ( 12 | CoordinatorEntity, 13 | DataUpdateCoordinator, 14 | UpdateFailed, 15 | ) 16 | 17 | 18 | async def async_setup_entry(hass, entry, async_add_devices): 19 | coordinator = hass.data[DOMAIN][entry.entry_id] 20 | async_add_devices( 21 | [ 22 | AlsavoProSensor(coordinator, 23 | SensorDeviceClass.TEMPERATURE, 24 | "Water In", 25 | "°C", 26 | 16, 27 | False, 28 | "mdi:thermometer"), 29 | AlsavoProSensor(coordinator, 30 | SensorDeviceClass.TEMPERATURE, 31 | "Water Out", 32 | "°C", 33 | 17, 34 | False, 35 | "mdi:thermometer"), 36 | AlsavoProSensor(coordinator, 37 | SensorDeviceClass.TEMPERATURE, 38 | "Ambient", 39 | "°C", 40 | 18, 41 | False, 42 | "mdi:thermometer"), 43 | AlsavoProSensor(coordinator, 44 | SensorDeviceClass.TEMPERATURE, 45 | "Cold pipe", 46 | "°C", 47 | 19, 48 | False, 49 | "mdi:thermometer"), 50 | AlsavoProSensor(coordinator, 51 | SensorDeviceClass.TEMPERATURE, 52 | "heating pipe", 53 | "°C", 54 | 20, 55 | False, 56 | "mdi:thermometer"), 57 | AlsavoProSensor(coordinator, 58 | SensorDeviceClass.TEMPERATURE, 59 | "IPM module", 60 | "°C", 61 | 21, 62 | False, 63 | "mdi:thermometer"), 64 | AlsavoProSensor(coordinator, 65 | SensorDeviceClass.TEMPERATURE, 66 | "Exhaust temperature", 67 | "°C", 68 | 23, 69 | False, 70 | "mdi:thermometer"), 71 | AlsavoProSensor(coordinator, 72 | SensorDeviceClass.TEMPERATURE, 73 | "Heating mode target", 74 | "°C", 75 | 1, 76 | True, 77 | "mdi:thermometer"), 78 | AlsavoProSensor(coordinator, 79 | SensorDeviceClass.TEMPERATURE, 80 | "Cooling mode target", 81 | "°C", 82 | 2, 83 | True, 84 | "mdi:thermometer"), 85 | AlsavoProSensor(coordinator, 86 | SensorDeviceClass.TEMPERATURE, 87 | "Auto mode target", 88 | "°C", 89 | 3, 90 | True, 91 | "mdi:thermometer"), 92 | AlsavoProSensor(coordinator, 93 | None, 94 | "Fan speed", 95 | "RPM", 96 | 22, 97 | False, 98 | "mdi:fan"), 99 | AlsavoProSensor(coordinator, 100 | SensorDeviceClass.CURRENT, 101 | "Compressor", 102 | "A", 103 | 26, 104 | False, 105 | "mdi:current-ac"), 106 | AlsavoProSensor(coordinator, 107 | SensorDeviceClass.FREQUENCY, 108 | "Compressor running frequency", 109 | "Hz", 110 | 27, 111 | False, 112 | "mdi:air-conditioner"), 113 | AlsavoProSensor(coordinator, 114 | None, 115 | "Frequency limit code", 116 | "", 117 | 34, 118 | False, 119 | "mdi:bell-alert"), 120 | AlsavoProSensor(coordinator, 121 | None, 122 | "Alarm code 1", 123 | "", 124 | 48, 125 | False, 126 | "mdi:bell-alert"), 127 | AlsavoProSensor(coordinator, 128 | None, 129 | "Alarm code 2", 130 | "", 131 | 49, 132 | False, 133 | "mdi:bell-alert"), 134 | AlsavoProSensor(coordinator, 135 | None, 136 | "Alarm code 3", 137 | "", 138 | 50, 139 | False, 140 | "mdi:bell-alert"), 141 | AlsavoProSensor(coordinator, 142 | None, 143 | "Alarm code 4", 144 | "", 145 | 51, 146 | False, 147 | "mdi:bell-alert"), 148 | AlsavoProSensor(coordinator, 149 | None, 150 | "Alarm code 4", 151 | "", 152 | 51, 153 | False, 154 | "mdi:bell-alert"), 155 | AlsavoProSensor(coordinator, 156 | None, 157 | "System status code", 158 | "", 159 | 52, 160 | False, 161 | "mdi:state-machine"), 162 | AlsavoProSensor(coordinator, 163 | None, 164 | "System running code", 165 | "", 166 | 53, 167 | False, 168 | "mdi:state-machine"), 169 | AlsavoProSensor(coordinator, 170 | None, 171 | "Device type", 172 | "", 173 | 64, 174 | False, 175 | "mdi:heat-pump"), 176 | AlsavoProSensor(coordinator, 177 | None, 178 | "Main board HW revision", 179 | "", 180 | 65, 181 | False, 182 | "mdi:heat-pump"), 183 | AlsavoProSensor(coordinator, 184 | None, 185 | "Main board SW revision", 186 | "", 187 | 66, 188 | False, 189 | "mdi:heat-pump"), 190 | AlsavoProSensor(coordinator, 191 | None, 192 | "Manual HW code", 193 | "", 194 | 67, 195 | False, 196 | "mdi:heat-pump"), 197 | AlsavoProSensor(coordinator, 198 | None, 199 | "Manual SW code", 200 | "", 201 | 68, 202 | False, 203 | "mdi:heat-pump"), 204 | AlsavoProSensor(coordinator, 205 | None, 206 | "Power mode", 207 | "", 208 | 16, 209 | True, 210 | "mdi:heat-pump"), 211 | AlsavoProErrorSensor(coordinator, 212 | "Error messages"), 213 | ] 214 | ) 215 | 216 | 217 | class AlsavoProSensor(CoordinatorEntity, SensorEntity): 218 | def __init__(self, coordinator: AlsavoProDataCoordinator, 219 | device_class: SensorDeviceClass, 220 | name: str, 221 | unit: str, 222 | idx: int, 223 | from_config: bool, 224 | icon: str): 225 | super().__init__(coordinator) 226 | self.data_coordinator = coordinator 227 | self._data_handler = self.data_coordinator.data_handler 228 | self._name = name 229 | self._attr_device_class = device_class 230 | self._attr_native_unit_of_measurement = unit 231 | self._dataIdx = idx 232 | self._config = from_config 233 | self._icon = icon 234 | 235 | @property 236 | def name(self): 237 | """Return the name of the sensor.""" 238 | return f"{DOMAIN}_{self._data_handler.name}_{self._name}" 239 | 240 | # This property is important to let HA know if this entity is online or not. 241 | # If an entity is offline (return False), the UI will reflect this. 242 | @property 243 | def available(self) -> bool: 244 | """Return True if roller and hub is available.""" 245 | return self._data_handler.is_online 246 | 247 | @property 248 | def unique_id(self): 249 | """Return a unique ID.""" 250 | return f"{self._data_handler.unique_id}_{self._name}" 251 | 252 | @property 253 | def native_value(self): 254 | # Hent data fra data_handler her 255 | if self._attr_device_class == SensorDeviceClass.TEMPERATURE: 256 | if self._config: 257 | return self._data_handler.get_temperature_from_config(self._dataIdx) 258 | else: 259 | return self._data_handler.get_temperature_from_status(self._dataIdx) 260 | else: 261 | if self._config: 262 | return self._data_handler.get_config_value(self._dataIdx) 263 | else: 264 | return self._data_handler.get_status_value(self._dataIdx) 265 | 266 | @property 267 | def icon(self): 268 | return self._icon 269 | 270 | 271 | class AlsavoProErrorSensor(CoordinatorEntity, SensorEntity): 272 | def __init__(self, coordinator: AlsavoProDataCoordinator, 273 | name: str): 274 | super().__init__(coordinator) 275 | self.data_coordinator = coordinator 276 | self._data_handler = self.data_coordinator.data_handler 277 | self._name = name 278 | self._icon = "mdi:alert" 279 | 280 | @property 281 | def name(self): 282 | """Return the name of the sensor.""" 283 | return f"{DOMAIN}_{self._data_handler.name}_{self._name}" 284 | 285 | @property 286 | def unique_id(self): 287 | """Return a unique ID.""" 288 | return f"{self._data_handler.unique_id}_{self._name}" 289 | 290 | @property 291 | def native_value(self): 292 | return self._data_handler.errors 293 | 294 | @property 295 | def icon(self): 296 | return self._icon 297 | 298 | async def async_update(self): 299 | """Get the latest data.""" 300 | self._data_handler = self.data_coordinator.data_handler 301 | -------------------------------------------------------------------------------- /custom_components/alsavopro/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" 5 | }, 6 | "error": { 7 | "connection_error": "[%key:common::config_flow::error::connection_error%]" 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "name": "[%key:common::config_flow::data::name%]", 13 | "serial_no": "Heatpump serial no", 14 | "ip_address": "[%key:common::config_flow::data::ip%]", 15 | "port": "[%key:common::config_flow::data::port%]", 16 | "password": "[%key:common::config_flow::data::password%]" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /custom_components/alsavopro/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Account is already configured" 5 | }, 6 | "error": { 7 | "connection_error": "Failed to connect" 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "name": "Device name", 13 | "serial_no": "Heatpump serial no", 14 | "ip_address": "IP-address", 15 | "port": "Port no", 16 | "password": "Password" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /custom_components/alsavopro/udpclient.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | class UDPClient: 5 | """ Async UDP client """ 6 | def __init__(self, server_host, server_port): 7 | self.server_host = server_host 8 | self.server_port = server_port 9 | self.loop = asyncio.get_event_loop() 10 | 11 | class SimpleClientProtocol(asyncio.DatagramProtocol): 12 | # Sending only 13 | def __init__(self, message): 14 | self.message = message 15 | self.transport = None 16 | 17 | def connection_made(self, transport): 18 | self.transport = transport 19 | self.transport.sendto(self.message) 20 | self.transport.close() 21 | 22 | class EchoClientProtocol(asyncio.DatagramProtocol): 23 | # Send and receive 24 | def __init__(self, message, future): 25 | self.message = message 26 | self.future = future 27 | self.transport = None 28 | 29 | def connection_made(self, transport): 30 | self.transport = transport 31 | self.transport.sendto(self.message) 32 | 33 | def datagram_received(self, data, addr): 34 | self.future.set_result(data) 35 | self.transport.close() 36 | 37 | def error_received(self, exc): 38 | self.future.set_exception(exc) 39 | 40 | def connection_lost(self, exc): 41 | if not self.future.done(): 42 | self.future.set_exception(ConnectionError("Connection lost")) 43 | 44 | async def send_rcv(self, bytes_to_send): 45 | future = self.loop.create_future() 46 | transport, protocol = await self.loop.create_datagram_endpoint( 47 | lambda: self.EchoClientProtocol(bytes_to_send, future), 48 | remote_addr=(self.server_host, self.server_port) 49 | ) 50 | 51 | try: 52 | data = await asyncio.wait_for(future, timeout=5.0) 53 | return data, b'0' 54 | except asyncio.TimeoutError: 55 | _LOGGER.error("Timeout: No response from server in 5 seconds.") 56 | return None 57 | finally: 58 | transport.close() 59 | 60 | async def send(self, bytes_to_send): 61 | transport, protocol = await self.loop.create_datagram_endpoint( 62 | lambda: self.SimpleClientProtocol(bytes_to_send), 63 | remote_addr=(self.server_host, self.server_port) 64 | ) 65 | transport.close() 66 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goev/AlsavoProHomeAssistantIntegration/b270ec52337427851b59da4f9be1bf1b60f0f9cd/hacs.json --------------------------------------------------------------------------------