From 8554d6a2dbd120e037a748e613bd7c05a4ee5e84 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 18:54:13 +0100 Subject: [PATCH 01/10] added manual fan handling for v2 fans --- custom_components/siku/api_v2.py | 22 +++++++++++++++++++++- custom_components/siku/fan.py | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 72f28e4..c3a672c 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -2,6 +2,7 @@ import logging import socket +from homeassistant.util.percentage import percentage_to_ranged_value from .const import DIRECTION_ALTERNATING from .const import DIRECTIONS @@ -78,6 +79,9 @@ MODE_PARTY: PRESET_MODE_PARTY, } +SPEED_MANUAL_MIN = 0 +SPEED_MANUAL_MAX = 255 + class SikuV2Api: """Handle requests to the fan controller.""" @@ -95,6 +99,7 @@ async def status(self) -> dict: COMMAND_DEVICE_TYPE, COMMAND_ON_OFF, COMMAND_SPEED, + COMMAND_MANUAL_SPEED, COMMAND_DIRECTION, COMMAND_BOOST, COMMAND_MODE, @@ -129,6 +134,13 @@ async def speed(self, speed: str) -> None: await self._send_command(FUNC_READ_WRITE, cmd) return await self.status() + async def speed_manual(self, speed: str) -> None: + """Set manual fan speed.""" + speed = percentage_to_ranged_value(SPEED_MANUAL_MIN, SPEED_MANUAL_MAX, speed) + cmd = f"{COMMAND_MANUAL_SPEED}{speed}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + async def direction(self, direction: str) -> None: """Set fan direction.""" # if direction is in DIRECTIONS values translate it to the key value @@ -252,7 +264,11 @@ async def _translate_response(self, data: dict) -> dict: try: speed = f"{int(data[COMMAND_SPEED], 16):02}" except KeyError: - speed = "00" + speed = "FF" + try: + manual_speed = f"{int(data[COMMAND_MANUAL_SPEED], 16):02}" + except KeyError: + manual_speed = "00" try: direction = DIRECTIONS[data[COMMAND_DIRECTION]] oscillating = bool(direction == DIRECTION_ALTERNATING) @@ -301,6 +317,10 @@ async def _translate_response(self, data: dict) -> dict: return { "is_on": is_on, "speed": speed, + "speed_list": FAN_SPEEDS, + "manual_speed_selected": bool(speed == "FF"), + "manual_speed": int(data["manual_speed"]), + "manual_speed_low_high_range": (SPEED_MANUAL_MIN, SPEED_MANUAL_MAX), "oscillating": oscillating, "direction": direction, "boost": boost, diff --git a/custom_components/siku/fan.py b/custom_components/siku/fan.py index 40fa859..70d1983 100644 --- a/custom_components/siku/fan.py +++ b/custom_components/siku/fan.py @@ -107,7 +107,7 @@ async def async_set_percentage(self, percentage: int) -> None: self.coordinator.data["manual_speed_selected"] and self.coordinator.data["manual_speed"] ): - await self.coordinator.api.speed(percentage) + await self.coordinator.api.speed_manual(percentage) elif self.coordinator.data["speed_list"]: await self.coordinator.api.speed( percentage_to_ordered_list_item( From 43d023c92110ea6744c772aa29a4871f75ee3a7f Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 18:54:58 +0100 Subject: [PATCH 02/10] bump version --- custom_components/siku/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/siku/manifest.json b/custom_components/siku/manifest.json index 8b82a91..714b55e 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -13,6 +13,6 @@ "issue_tracker": "https://github.com/hmn/siku-integration/issues", "requirements": [], "ssdp": [], - "version": "2.2.0", + "version": "2.2.1", "zeroconf": [] } \ No newline at end of file From f51f5171a7d918e7e679aec88afa7def7571e488 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 19:26:14 +0100 Subject: [PATCH 03/10] fix variable ref --- custom_components/siku/api_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index c3a672c..8fc7b17 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -319,7 +319,7 @@ async def _translate_response(self, data: dict) -> dict: "speed": speed, "speed_list": FAN_SPEEDS, "manual_speed_selected": bool(speed == "FF"), - "manual_speed": int(data["manual_speed"]), + "manual_speed": int(manual_speed, 16), "manual_speed_low_high_range": (SPEED_MANUAL_MIN, SPEED_MANUAL_MAX), "oscillating": oscillating, "direction": direction, From 3e3c43309e5d93c285d00f197910c223faeddf8a Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 20:06:49 +0100 Subject: [PATCH 04/10] fix filter countdown days --- custom_components/siku/api_v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 8fc7b17..e726e43 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -295,9 +295,9 @@ async def _translate_response(self, data: dict) -> dict: # Byte 1: Minutes (0...59) # Byte 2: Hours (0...23) # Byte 3: Days (0...181) - minutes = int(data[COMMAND_FILTER_TIMER][0:2], 16) + days = int(data[COMMAND_FILTER_TIMER][0:2], 16) hours = int(data[COMMAND_FILTER_TIMER][2:4], 16) - days = int(data[COMMAND_FILTER_TIMER][4:6], 16) + minutes = int(data[COMMAND_FILTER_TIMER][4:6], 16) filter_timer = int(minutes + hours * 60 + days * 24 * 60) except KeyError: filter_timer = 0 From a4a2692fce5e6b299b47f7b2eb6455b794254875 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 20:21:11 +0100 Subject: [PATCH 05/10] reset alarm uses write --- custom_components/siku/api_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index e726e43..1f60c89 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -169,7 +169,7 @@ async def party(self) -> None: async def reset_filter_alarm(self) -> None: """Reset filter alarm.""" cmd = f"{COMMAND_RESET_ALARMS}".upper() - await self._send_command(FUNC_READ_WRITE, cmd) + await self._send_command(FUNC_WRITE, cmd) return await self.status() def _checksum(self, data: str) -> str: From c27989e0dc22990bede52924dc4623f224c03cc3 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 20:38:10 +0100 Subject: [PATCH 06/10] test filter reset --- custom_components/siku/api_v2.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 1f60c89..0233679 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -168,7 +168,7 @@ async def party(self) -> None: async def reset_filter_alarm(self) -> None: """Reset filter alarm.""" - cmd = f"{COMMAND_RESET_ALARMS}".upper() + cmd = f"{COMMAND_RESET_ALARMS}00".upper() await self._send_command(FUNC_WRITE, cmd) return await self.status() @@ -237,6 +237,10 @@ async def _send_command(self, func: str, data: str) -> list[str]: ) s.sendto(packet_data, server_address) + if func == FUNC_WRITE: + LOGGER.debug("write command, no response expected") + return [] + # Receive response result_data, server = s.recvfrom(256) LOGGER.debug( @@ -250,7 +254,7 @@ async def _send_command(self, func: str, data: str) -> list[str]: result_hexlist = ["".join(x) for x in zip(*[iter(result_str)] * 2)] if not self._verify_checksum(result_hexlist): - raise Exception("Checksum error") + raise ValueError("Checksum error") LOGGER.debug("returning hexlist %s", result_hexlist) return result_hexlist @@ -417,7 +421,7 @@ async def _parse_response(self, hexlist: list[str]) -> dict: ) i += 1 except KeyError as ex: - raise Exception( + raise ValueError( f"Error translating response from fan controller: {str(ex)}" ) from ex return data From 608af56b5ee18222b4a7da6537b06eebd23afb57 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 20:52:42 +0100 Subject: [PATCH 07/10] retry filter reset --- custom_components/siku/api_v2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 0233679..da25cfb 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -79,6 +79,8 @@ MODE_PARTY: PRESET_MODE_PARTY, } +EMPTY_VALUE = "00" + SPEED_MANUAL_MIN = 0 SPEED_MANUAL_MAX = 255 @@ -168,7 +170,7 @@ async def party(self) -> None: async def reset_filter_alarm(self) -> None: """Reset filter alarm.""" - cmd = f"{COMMAND_RESET_ALARMS}00".upper() + cmd = f"{COMMAND_RESET_ALARMS}{EMPTY_VALUE}{COMMAND_RESET_FILTER_TIMER}{EMPTY_VALUE}".upper() await self._send_command(FUNC_WRITE, cmd) return await self.status() From 6615c5daec63b6f055a4831ce928dd8c695c75bf Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 21:06:52 +0100 Subject: [PATCH 08/10] test packet retry --- custom_components/siku/api_v2.py | 77 +++++++++++++++++--------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index da25cfb..c87b2e4 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -223,42 +223,47 @@ async def _send_command(self, func: str, data: str) -> list[str]: # enter the data content of the UDP packet as hex packet_str = self._build_packet(func, data) packet_data = bytes.fromhex(packet_str) - LOGGER.debug("packet data: %s", packet_data) - - # initialize a socket, think of it as a cable - # SOCK_DGRAM specifies that this is UDP - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) as s: - s.settimeout(1) - - server_address = (self.host, self.port) - LOGGER.debug( - 'sending "%s" size(%s) to %s', - packet_data.hex(), - len(packet_data), - server_address, - ) - s.sendto(packet_data, server_address) - - if func == FUNC_WRITE: - LOGGER.debug("write command, no response expected") - return [] - - # Receive response - result_data, server = s.recvfrom(256) - LOGGER.debug( - "receive data: %s size(%s) from %s", - result_data, - len(result_data), - server, - ) - result_str = result_data.hex().upper() - LOGGER.debug("receive string: %s", result_str) - - result_hexlist = ["".join(x) for x in zip(*[iter(result_str)] * 2)] - if not self._verify_checksum(result_hexlist): - raise ValueError("Checksum error") - LOGGER.debug("returning hexlist %s", result_hexlist) - return result_hexlist + + for attempt in range(3): # Retry up to 3 times + try: + # initialize a socket, think of it as a cable + # SOCK_DGRAM specifies that this is UDP + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) as s: + s.settimeout(1) + server_address = (self.host, self.port) + LOGGER.debug( + 'sending "%s" size(%s) to %s', + packet_data.hex(), + len(packet_data), + server_address, + ) + s.sendto(packet_data, server_address) + + if func == FUNC_WRITE: + LOGGER.debug("write command, no response expected") + s.close() + return [] + + # Receive response + result_data, server = s.recvfrom(4096) + LOGGER.debug( + 'received "%s" size(%s) from %s', + result_data, + len(result_data), + server, + ) + result_str = result_data.hex().upper() + LOGGER.debug("receive string: %s", result_str) + + result_hexlist = ["".join(x) for x in zip(*[iter(result_str)] * 2)] + if not self._verify_checksum(result_hexlist): + raise ValueError("Checksum error") + LOGGER.debug("returning hexlist %s", result_hexlist) + return result_hexlist + except TimeoutError: + LOGGER.warning("Timeout occurred, retrying... (%d/3)", attempt + 1) + if attempt == 2: + raise TimeoutError("Failed to send command after 3 attempts") async def _translate_response(self, data: dict) -> dict: """Translate response data to dict.""" From fd2f5b6b6a7dc8549dcd57f37b7005b73b5e0dc8 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 21:41:34 +0100 Subject: [PATCH 09/10] set units --- custom_components/siku/api_v1.py | 2 +- custom_components/siku/api_v2.py | 9 ++++++++- custom_components/siku/sensor.py | 28 +++++++++++++++++++--------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/custom_components/siku/api_v1.py b/custom_components/siku/api_v1.py index 4f9a2d1..613a3ab 100644 --- a/custom_components/siku/api_v1.py +++ b/custom_components/siku/api_v1.py @@ -539,7 +539,7 @@ async def _format_response(self, data: dict) -> dict: "mode": data["operation_mode"], "humidity": int(data["humidity_level"]), "alarm": bool(data["alarm_status"] == NoYes.YES), - "filter_timer": int(data["timer_countdown"]), + "timer_countdown": int(data["timer_countdown"]), "boost": bool( data["boost_mode_after_sensor"] == NoYesYes.YES or data["boost_mode_after_sensor"] == NoYesYes.YES2 diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index c87b2e4..35e706b 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -39,6 +39,7 @@ COMMAND_DEVICE_TYPE = "B9" COMMAND_BOOST = "06" COMMAND_MODE = "07" +COMMAND_TIMER_COUNTDOWN = "11" COMMAND_CURRENT_HUMIDITY = "25" COMMAND_MANUAL_SPEED = "44" COMMAND_FAN1RPM = "4A" @@ -105,6 +106,7 @@ async def status(self) -> dict: COMMAND_DIRECTION, COMMAND_BOOST, COMMAND_MODE, + COMMAND_TIMER_COUNTDOWN, COMMAND_CURRENT_HUMIDITY, COMMAND_FAN1RPM, COMMAND_FILTER_TIMER, @@ -325,6 +327,10 @@ async def _translate_response(self, data: dict) -> dict: firmware = f"{int(data[COMMAND_READ_FIRMWARE_VERSION][0], 16)}.{int(data[COMMAND_READ_FIRMWARE_VERSION][1], 16)}" except KeyError: firmware = None + try: + timer_countdown = int(data[COMMAND_TIMER_COUNTDOWN], 16) + except KeyError: + timer_countdown = 0 return { "is_on": is_on, "speed": speed, @@ -339,7 +345,8 @@ async def _translate_response(self, data: dict) -> dict: "humidity": humidity, "rpm": rpm, "firmware": firmware, - "filter_timer": filter_timer, + "filter_timer_days": filter_timer, + "timer_countdown": timer_countdown, "alarm": alarm, "version": "2", } diff --git a/custom_components/siku/sensor.py b/custom_components/siku/sensor.py index e4a3fd0..fd73bfe 100644 --- a/custom_components/siku/sensor.py +++ b/custom_components/siku/sensor.py @@ -67,44 +67,54 @@ class SikuSensorEntityDescription(SensorEntityDescription): icon="mdi:alarm-light", ), SikuSensorEntityDescription( - key="filter_timer", + key="filter_timer_days", name="Filter timer countdown", icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, - suggested_display_precision=2, + suggested_display_precision=0, suggested_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, + ), + SikuSensorEntityDescription( + key="timer_countdown", + name="Timer countdown", + icon="mdi:timer", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, ), SikuSensorEntityDescription( key="boost_mode_timer", name="Boost mode timer", icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, - suggested_display_precision=2, + suggested_display_precision=0, suggested_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), SikuSensorEntityDescription( key="night_mode_timer", name="Sleep mode timer", icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, - suggested_display_precision=2, + suggested_display_precision=0, suggested_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), SikuSensorEntityDescription( key="party_mode_timer", name="Party mode timer", icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, - suggested_display_precision=2, + suggested_display_precision=0, suggested_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), SikuSensorEntityDescription( key="boost", From 2e3df103b19ff031aba50c02a19fba1a5a1e7845 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 9 Nov 2024 21:48:11 +0100 Subject: [PATCH 10/10] fix timer return value --- custom_components/siku/api_v2.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 35e706b..d8c19d1 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -39,7 +39,7 @@ COMMAND_DEVICE_TYPE = "B9" COMMAND_BOOST = "06" COMMAND_MODE = "07" -COMMAND_TIMER_COUNTDOWN = "11" +COMMAND_TIMER_COUNTDOWN = "0B" COMMAND_CURRENT_HUMIDITY = "25" COMMAND_MANUAL_SPEED = "44" COMMAND_FAN1RPM = "4A" @@ -328,7 +328,13 @@ async def _translate_response(self, data: dict) -> dict: except KeyError: firmware = None try: - timer_countdown = int(data[COMMAND_TIMER_COUNTDOWN], 16) + # Byte 1 – seconds (0…59) + # Byte 2 – minutes (0…59) + # Byte 3 – hours (0…23) + hours = int(data[COMMAND_TIMER_COUNTDOWN][0:2], 16) + minutes = int(data[COMMAND_TIMER_COUNTDOWN][2:4], 16) + seconds = int(data[COMMAND_TIMER_COUNTDOWN][4:6], 16) + timer_countdown = int(seconds + minutes * 60 + hours * 60 * 60) except KeyError: timer_countdown = 0 return {