From dd0e81bc03bcde4b21af5a7d533f7b5e628da298 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Wed, 23 Nov 2022 19:45:18 +0100 Subject: [PATCH] added initial v2 support (#15) * added initial v2 support * fixed linter errors * added mode support for v2 and fixed speed handling --- .cookiecutter.json | 2 +- .devcontainer/devcontainer.json | 3 +- .gitignore | 1 + README.md | 18 +- custom_components/siku/__init__.py | 4 +- custom_components/siku/{api.py => api_v1.py} | 58 ++-- custom_components/siku/api_v2.py | 320 +++++++++++++++++++ custom_components/siku/config_flow.py | 35 +- custom_components/siku/const.py | 21 +- custom_components/siku/coordinator.py | 20 +- custom_components/siku/fan.py | 15 +- custom_components/siku/manifest.json | 4 +- custom_components/siku/strings.json | 9 +- custom_components/siku/translations/en.json | 9 +- hacs.json | 2 +- requirements_dev.txt | 9 +- requirements_test.txt | 3 + 17 files changed, 455 insertions(+), 78 deletions(-) rename custom_components/siku/{api.py => api_v1.py} (77%) create mode 100644 custom_components/siku/api_v2.py create mode 100644 requirements_test.txt diff --git a/.cookiecutter.json b/.cookiecutter.json index b9e08a2..5153b7a 100644 --- a/.cookiecutter.json +++ b/.cookiecutter.json @@ -2,7 +2,7 @@ "_template": "gh:oncleben31/cookiecutter-homeassistant-custom-component", "class_name_prefix": "Siku", "domain_name": "siku", - "friendly_name": "Siku RV Fan integration", + "friendly_name": "Siku Fan", "github_user": "hmn", "project_name": "hacs-siku-integration", "test_suite": "yes", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 413890d..ea38ab0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "Siku RV Fan integration integration development", + "name": "Siku Fan integration development", "context": "..", "appPort": ["9123:8123"], "postCreateCommand": "container install", @@ -15,7 +15,6 @@ "settings": { "files.eol": "\n", "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/local/python/bin/python", "python.analysis.autoSearchPaths": false, "python.linting.pylintEnabled": true, diff --git a/.gitignore b/.gitignore index a4a4d84..c2ddcf3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ pythonenv* .coverage venv .venv +.DS_Store diff --git a/README.md b/README.md index 23464b2..c6c7282 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Siku RV Fan integration +# Siku Fan integration [![GitHub Release][releases-shield]][releases] [![GitHub Activity][commits-shield]][commits] @@ -18,11 +18,21 @@ | Platform | Description | | -------- | ----------- | -| `fan` | Siku RV Fan | +| `fan` | Siku Fan | Integration for https://www.siku.at/produkte/ wifi fans -Tested on "SIKU RV 50 W Pro WIFI v1" +### Tested on + +- "Siku RV 50 W Pro WIFI v1" +- ? + +The fan is sold under different brands, for instance : + +- Siku RV +- SIKU TwinFresh +- DUKA One +- Oxxify ## Installation @@ -32,7 +42,7 @@ Tested on "SIKU RV 50 W Pro WIFI v1" 4. Download _all_ the files from the `custom_components/siku/` directory (folder) in this repository. 5. Place the files you downloaded in the new directory (folder) you created. 6. Restart Home Assistant -7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Siku RV Fan integration" +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Siku Fan integration" Using your HA configuration directory (folder) as a starting point you should now also have this: diff --git a/custom_components/siku/__init__.py b/custom_components/siku/__init__.py index af1f03b..fab3186 100644 --- a/custom_components/siku/__init__.py +++ b/custom_components/siku/__init__.py @@ -1,4 +1,4 @@ -"""The Siku RV Fan integration.""" +"""The Siku Fan integration.""" from __future__ import annotations from homeassistant.config_entries import ConfigEntry @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Siku RV Fan from a config entry.""" + """Set up Siku Fan from a config entry.""" coordinator = SikuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/siku/api.py b/custom_components/siku/api_v1.py similarity index 77% rename from custom_components/siku/api.py rename to custom_components/siku/api_v1.py index c527cdb..ee70421 100644 --- a/custom_components/siku/api.py +++ b/custom_components/siku/api_v1.py @@ -2,23 +2,18 @@ import logging import socket -from .const import PACKET_POSTFIX -from .const import PACKET_PREFIX +from .const import DIRECTION_ALTERNATING +from .const import DIRECTIONS +from .const import FAN_SPEEDS LOGGER = logging.getLogger(__name__) -FAN_SPEEDS = ["01", "02", "03"] # forward = pull air out of the room # reverse = pull air into the room from outside # alternating = change directions (used for oscilating option in fan) -DIRECTION_FORWARD = "00" -DIRECTION_ALTERNATING = "01" -DIRECTION_REVERSE = "02" -DIRECTIONS = { - DIRECTION_FORWARD: "forward", - DIRECTION_ALTERNATING: "alternating", - DIRECTION_REVERSE: "reverse", -} +PACKET_PREFIX = bytes.fromhex("6d6f62696c65") +PACKET_POSTFIX = bytes.fromhex("0d0a") + COMMAND_STATUS = "01" COMMAND_DIRECTION = "06" COMMAND_SPEED = "04" @@ -33,7 +28,7 @@ RESULT_POWER_OFF = "00" -class SikuApi: +class SikuV1Api: """Handle requests to the fan controller.""" def __init__(self, host: str, port: int) -> None: @@ -102,28 +97,23 @@ async def _send_command(self, command: str) -> list[str]: # enter the data content of the UDP packet as hex packet_data = PACKET_PREFIX + packet_command + PACKET_POSTFIX - 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(10) - - server_address = (self.host, self.port) - LOGGER.debug('sending "%s" to %s', packet_data, server_address) - s.sendto(packet_data, server_address) - - # Receive response - data, server = s.recvfrom(4096) - LOGGER.debug('received "%s" from %s', data, server) - - hexstring = data.hex() - hexlist = ["".join(x) for x in zip(*[iter(hexstring)] * 2)] - LOGGER.debug("returning hexlist %s", hexlist) - return hexlist - except Exception as ex: - raise Exception( - f"Error sending command to fan controller: {str(ex)}" - ) from ex + # 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(10) + + server_address = (self.host, self.port) + LOGGER.debug('sending "%s" to %s', packet_data, server_address) + s.sendto(packet_data, server_address) + + # Receive response + data, server = s.recvfrom(4096) + LOGGER.debug('received "%s" from %s', data, server) + + hexstring = data.hex() + hexlist = ["".join(x) for x in zip(*[iter(hexstring)] * 2)] + LOGGER.debug("returning hexlist %s", hexlist) + return hexlist async def _translate_response(self, hexlist: list[str]) -> dict: """Translate response from fan controller.""" diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py new file mode 100644 index 0000000..2924d80 --- /dev/null +++ b/custom_components/siku/api_v2.py @@ -0,0 +1,320 @@ +"""Helper api function for sending commands to the fan controller.""" +import logging +import socket + +from .const import DIRECTION_ALTERNATING +from .const import DIRECTIONS +from .const import FAN_SPEEDS +from .const import PRESET_MODE_AUTO +from .const import PRESET_MODE_PARTY +from .const import PRESET_MODE_SLEEP + +LOGGER = logging.getLogger(__name__) + +# forward = pull air out of the room +# reverse = pull air into the room from outside +# alternating = change directions (used for oscilating option in fan) + +PACKET_PREFIX = "FDFD" +PACKET_PROTOCOL_TYPE = "02" +PACKET_SIZE_ID = "10" + +FUNC_READ = "01" +FUNC_WRITE = "02" +FUNC_READ_WRITE = "03" +FUNC_INC = "04" +FUNC_DEC = "05" +FUNC_RESULT = "06" # result func (FUNC = 0x01, 0x03, 0x04, 0x05). + +RETURN_CHANGE_FUNC = "FC" +RETURN_INVALID = "FD" +RETURN_VALUE_SIZE = "FE" +RETURN_HIGH_BYTE = "FF" + +COMMAND_ON_OFF = "01" +COMMAND_SPEED = "02" +COMMAND_DIRECTION = "B7" +COMMAND_DEVICE_TYPE = "B9" +COMMAND_MODE = "07" + +COMMAND_FUNCTION_R = "01" +COMMAND_FUNCTION_W = "02" +COMMAND_FUNCTION_RW = "03" +COMMAND_FUNCTION_INC = "04" +COMMAND_FUNCTION_DEC = "05" + +POWER_OFF = "00" +POWER_ON = "01" +POWER_TOGGLE = "02" + +MODE_OFF = "01" +MODE_SLEEP = "01" +MODE_PARTY = "02" +MODES = { + MODE_OFF: PRESET_MODE_AUTO, + MODE_SLEEP: PRESET_MODE_SLEEP, + MODE_PARTY: PRESET_MODE_PARTY, +} + + +class SikuV2Api: + """Handle requests to the fan controller.""" + + def __init__(self, host: str, port: int, idnum: str, password: str) -> None: + """Initialize.""" + self.host = host + self.port = port + self.idnum = idnum + self.password = password + + async def status(self) -> dict: + """Get status from fan controller.""" + cmd = f"{COMMAND_DEVICE_TYPE}{COMMAND_ON_OFF}{COMMAND_SPEED}{COMMAND_DIRECTION}{COMMAND_MODE}".upper() + hexlist = await self._send_command(FUNC_READ, cmd) + data = await self._parse_response(hexlist) + return await self._translate_response(data) + + async def power_on(self) -> None: + """Power on fan""" + cmd = f"{COMMAND_ON_OFF}{POWER_ON}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + + async def power_off(self) -> None: + """Power off fan""" + cmd = f"{COMMAND_ON_OFF}{POWER_OFF}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + + async def speed(self, speed: str) -> None: + """Set fan speed""" + if speed not in FAN_SPEEDS: + raise ValueError(f"Invalid fan speed: {speed}") + cmd = f"{COMMAND_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 + if direction in DIRECTIONS.values(): + direction = list(DIRECTIONS.keys())[ + list(DIRECTIONS.values()).index(direction) + ] + if direction not in DIRECTIONS: + raise ValueError(f"Invalid fan direction: {direction}") + cmd = f"{COMMAND_DIRECTION}{direction}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + + async def sleep(self) -> None: + """Set fan to sleep mode""" + cmd = f"{COMMAND_ON_OFF}{POWER_ON}{COMMAND_MODE}{MODE_SLEEP}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + + async def party(self) -> None: + """Set fan to party mode""" + cmd = f"{COMMAND_ON_OFF}{POWER_ON}{COMMAND_MODE}{MODE_PARTY}".upper() + await self._send_command(FUNC_READ_WRITE, cmd) + return await self.status() + + def _checksum(self, data: str) -> str: + """Calculate checksum for packet and return it as high order byte hex string.""" + hexlist = self._hexlist(data) + + checksum = 0 + for hexstr in hexlist[2:]: + checksum += int(hexstr, 16) + checksum_str = f"{checksum:04X}" + return f"{checksum_str[2:4]:02}{checksum_str[0:2]:02}" + + def _verify_checksum(self, hexlist: list[str]) -> bool: + """Verify checksum of packet.""" + checksum = self._checksum("".join(hexlist[0:-2])) + LOGGER.debug("checksum: %s", checksum) + LOGGER.debug("verify if %s == %s", checksum, hexlist[-2] + hexlist[-1]) + return checksum == hexlist[-2] + hexlist[-1] + + def _hexlist(self, hexstr: str) -> list[str]: + """Convert hex string to list of hex strings""" + return [hexstr[i : i + 2] for i in range(0, len(hexstr), 2)] + + def _login_packet(self) -> str: + """Build initial login part of packet""" + id_hex = self.idnum.encode("utf-8").hex() + password_size = f"{len(self.password):02x}" + password_hex = self.password.encode("utf-8").hex() + packet_str = ( + PACKET_PREFIX + + PACKET_PROTOCOL_TYPE + + PACKET_SIZE_ID + + id_hex + + password_size + + str(password_hex) + ).upper() + return packet_str + + def _build_packet(self, func: str, data: str) -> str: + """Build packet for sending to fan controller.""" + packet_str = (self._login_packet() + func + data).upper() + LOGGER.debug("packet string: %s", packet_str) + packet_str += self._checksum(packet_str) + LOGGER.debug("packet string: %s", packet_str) + return packet_str + + async def _send_command(self, func: str, data: str) -> list[str]: + """Send command to fan controller.""" + # 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(10) + + 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) + + # 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 Exception("Checksum error") + LOGGER.debug("returning hexlist %s", result_hexlist) + return result_hexlist + + async def _translate_response(self, data: dict) -> dict: + """Translate response data to dict.""" + LOGGER.debug("translate response: %s", data) + try: + is_on = bool(data[COMMAND_ON_OFF] == POWER_ON) + except KeyError: + is_on = False + try: + speed = f"{int(data[COMMAND_SPEED], 16):02}" + except KeyError: + speed = "00" + try: + direction = DIRECTIONS[data[COMMAND_DIRECTION]] + oscillating = bool(direction == DIRECTION_ALTERNATING) + except KeyError: + direction = None + oscillating = True + try: + mode = MODES[data[COMMAND_MODE]] + except KeyError: + mode = PRESET_MODE_AUTO + return { + "is_on": is_on, + "speed": speed, + "oscillating": oscillating, + "direction": direction, + "mode": mode, + } + + async def _parse_response(self, hexlist: list[str]) -> dict: + """Translate response from fan controller.""" + data = {} + try: + start = 0 + + # prefix + LOGGER.debug("start: %s", start) + LOGGER.debug("hexlist: %s", "".join(hexlist[start:2])) + if "".join(hexlist[0:2]) != PACKET_PREFIX: + raise Exception("Invalid packet prefix") + start += 2 + + # protocol type + LOGGER.debug("start: %s", start) + LOGGER.debug("hexlist: %s", "".join(hexlist[start])) + if "".join(hexlist[start]) != PACKET_PROTOCOL_TYPE: + raise Exception("Invalid packet protocol type") + start += 1 + + # id + LOGGER.debug("start: %s", start) + LOGGER.debug("hexlist: %s", "".join(hexlist[start])) + start += 1 + int("".join(hexlist[start]), 16) + + # password + LOGGER.debug("start: %s", start) + LOGGER.debug("hexlist: %s", "".join(hexlist[start])) + start += 1 + int("".join(hexlist[start]), 16) + + # function + if "".join(hexlist[start]) != FUNC_RESULT: + raise Exception("Invalid result function") + LOGGER.debug("start: %s", start) + LOGGER.debug("hexlist: %s", "".join(hexlist[start])) + start += 1 + + # data + LOGGER.debug("loop data %s %s", start, len(hexlist) - 2) + i = start + while i < (len(hexlist) - 2): + LOGGER.debug("parse data %s : %s", i, hexlist[i]) + parameter = hexlist[i] + value_size = 1 + cmd = "" + value = "" + if parameter == RETURN_CHANGE_FUNC: + LOGGER.debug("special function, change base function") + raise Exception( + "special function, change base function not implemented" + ) + elif parameter == RETURN_INVALID: + i += 1 + cmd = hexlist[i] + LOGGER.debug("special function, invalid cmd:%s", cmd) + elif parameter == RETURN_VALUE_SIZE: + i += 1 + value_size = int(hexlist[i], 16) + LOGGER.debug("special function, value size %s", value_size) + i += 1 + cmd = hexlist[i] + value = "".join(hexlist[i + 1 : i + 1 + value_size]) + # reverse byte order + value = "".join( + [value[idx : idx + 2] for idx in range(0, len(value), 2)][::-1] + ) + i += value_size + elif parameter == RETURN_HIGH_BYTE: + LOGGER.debug("special function, high byte") + raise Exception("special function, high byte not implemented") + else: + cmd = parameter + i += 1 + value = hexlist[i] + LOGGER.debug("normal function, cmd:%s value:%s", cmd, value) + + data.update({cmd: value}) + LOGGER.debug( + "return data cmd:%s value:%s", + cmd, + value, + ) + i += 1 + except KeyError as ex: + raise Exception( + f"Error translating response from fan controller: {str(ex)}" + ) from ex + return data diff --git a/custom_components/siku/config_flow.py b/custom_components/siku/config_flow.py index 025eca8..4a0d09e 100644 --- a/custom_components/siku/config_flow.py +++ b/custom_components/siku/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Siku RV Fan integration.""" +"""Config flow for Siku Fan integration.""" from __future__ import annotations import logging @@ -6,20 +6,28 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv -from .api import SikuApi +from .api_v1 import SikuV1Api +from .api_v2 import SikuV2Api +from .const import CONF_ID +from .const import CONF_VERSION from .const import DEFAULT_PORT from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_VERSION, default=2): vol.In([1, 2]), + vol.Optional(CONF_ID): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, } ) @@ -31,17 +39,22 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - siku_api = SikuApi(data[CONF_HOST], data[CONF_PORT]) - if not await siku_api.status(): + if data[CONF_VERSION] == 1: + api = SikuV1Api(data[CONF_IP_ADDRESS], data[CONF_PORT]) + else: + api = SikuV2Api( + data[CONF_IP_ADDRESS], data[CONF_PORT], data[CONF_ID], data[CONF_PASSWORD] + ) + if not await api.status(): raise CannotConnect # Return info that you want to store in the config entry. - title = f"{data[CONF_HOST]}:{data[CONF_PORT]}" + title = f"{data[CONF_IP_ADDRESS]}:{data[CONF_PORT]}" return {"title": title} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Siku RV Fan.""" + """Handle a config flow for Siku Fan.""" VERSION = 1 @@ -57,6 +70,10 @@ async def async_step_user( errors = {} try: info = await validate_input(self.hass, user_input) + except (ValueError, KeyError): + errors["base"] = "value_error" + except TimeoutError: + errors["base"] = "timeout_error" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/custom_components/siku/const.py b/custom_components/siku/const.py index aac231f..09dd5e4 100644 --- a/custom_components/siku/const.py +++ b/custom_components/siku/const.py @@ -1,4 +1,4 @@ -"""Constants for the Siku RV Fan integration.""" +"""Constants for the Siku Fan integration.""" DOMAIN = "siku" DEFAULT_MANUFACTURER = "Siku" @@ -6,5 +6,20 @@ DEFAULT_NAME = "Fan" DEFAULT_PORT = 4000 -PACKET_PREFIX = bytes.fromhex("6d6f62696c65") -PACKET_POSTFIX = bytes.fromhex("0d0a") +CONF_VERSION = "version" +CONF_ID = "idnum" + +FAN_SPEEDS = ["01", "02", "03"] +DIRECTION_FORWARD = "00" +DIRECTION_ALTERNATING = "01" +DIRECTION_REVERSE = "02" +DIRECTIONS = { + DIRECTION_FORWARD: "forward", + DIRECTION_ALTERNATING: "alternating", + DIRECTION_REVERSE: "reverse", +} + +PRESET_MODE_AUTO = "auto" +PRESET_MODE_ON = "on" +PRESET_MODE_PARTY = "party" +PRESET_MODE_SLEEP = "sleep" diff --git a/custom_components/siku/coordinator.py b/custom_components/siku/coordinator.py index e290417..d3f1bf6 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -6,14 +6,18 @@ from datetime import timedelta from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_PORT from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed -from .api import SikuApi +from .api_v1 import SikuV1Api +from .api_v2 import SikuV2Api +from .const import CONF_ID +from .const import CONF_VERSION LOGGER = logging.getLogger(__name__) @@ -32,7 +36,15 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: update_interval=timedelta(seconds=30), ) self.config_entry = entry - self.api = SikuApi(entry.data[CONF_HOST], entry.data[CONF_PORT]) + if entry.data[CONF_VERSION] == 1: + self.api = SikuV1Api(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]) + else: + self.api = SikuV2Api( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_PORT], + entry.data[CONF_ID], + entry.data[CONF_PASSWORD], + ) async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: """Get the latest data from Siku fan and updates the state.""" @@ -41,5 +53,5 @@ async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: data = await self.api.status() # self.logger.debug(data) except (socket.error, socket.timeout) as ex: - raise UpdateFailed(f"Connection to Siku RV Fan failed: {ex}") from ex + raise UpdateFailed(f"Connection to Siku Fan failed: {ex}") from ex return data diff --git a/custom_components/siku/fan.py b/custom_components/siku/fan.py index 576e8aa..5cd57d7 100644 --- a/custom_components/siku/fan.py +++ b/custom_components/siku/fan.py @@ -14,9 +14,13 @@ from homeassistant.util.percentage import percentage_to_ordered_list_item from . import SikuEntity -from .api import FAN_SPEEDS from .const import DEFAULT_NAME from .const import DOMAIN +from .const import FAN_SPEEDS +from .const import PRESET_MODE_AUTO +from .const import PRESET_MODE_ON +from .const import PRESET_MODE_PARTY +from .const import PRESET_MODE_SLEEP from .coordinator import SikuDataUpdateCoordinator LOGGER = logging.getLogger(__name__) @@ -24,18 +28,13 @@ # percentage = ordered_list_item_to_percentage(FAN_SPEEDS, "01") # named_speed = percentage_to_ordered_list_item(FAN_SPEEDS, 33) -PRESET_MODE_AUTO = "auto" -PRESET_MODE_ON = "on" -PRESET_MODE_PARTY = "party" -PRESET_MODE_SLEEP = "sleep" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Siku RV fan.""" + """Set up the Siku fan.""" async_add_entities( [ SikuFan( @@ -49,7 +48,7 @@ async def async_setup_entry( class SikuFan(SikuEntity, FanEntity): - """Siku RV Fan""" + """Siku Fan""" _attr_supported_features = ( FanEntityFeature.SET_SPEED diff --git a/custom_components/siku/manifest.json b/custom_components/siku/manifest.json index b8fdf17..9c0e951 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -1,6 +1,6 @@ { "domain": "siku", - "name": "Siku RV Fan", + "name": "Siku Fan", "config_flow": true, "documentation": "https://github.com/hmn/hacs-siku-integration", "issue_tracker": "https://github.com/hmn/hacs-siku-integration/issues", @@ -11,5 +11,5 @@ "dependencies": [], "codeowners": ["@hmn"], "iot_class": "local_polling", - "version": "1.0.2" + "version": "2.0.0" } diff --git a/custom_components/siku/strings.json b/custom_components/siku/strings.json index 1db9a26..401ce47 100644 --- a/custom_components/siku/strings.json +++ b/custom_components/siku/strings.json @@ -3,12 +3,17 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "ip_address": "[%key:common::config_flow::data::ip_address%]", + "port": "[%key:common::config_flow::data::port%]", + "version": "[%key:common::config_flow::data::version%]", + "idnum": "[%key:common::config_flow::data::idnum%]", + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { + "value_error": "[%key:common::config_flow::error::value_error%]", + "timeout_error": "[%key:common::config_flow::error::timeout_error%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/custom_components/siku/translations/en.json b/custom_components/siku/translations/en.json index aeb233a..ceed9a2 100644 --- a/custom_components/siku/translations/en.json +++ b/custom_components/siku/translations/en.json @@ -4,6 +4,8 @@ "already_configured": "Device is already configured" }, "error": { + "value_error": "Incorrect values defined in form", + "timeout_error": "Timeout error, failed to get status", "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" @@ -11,8 +13,11 @@ "step": { "user": { "data": { - "host": "Host", - "port": "Port" + "ip_address": "IP Address", + "port": "Port", + "version": "Hardware Version", + "idnum": "Fan ID (only v2)", + "password": "Fan Password (only v2)" } } } diff --git a/hacs.json b/hacs.json index 90c13e6..7e10905 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { - "name": "Siku RV Fan", + "name": "Siku Fan", "render_readme": true } diff --git a/requirements_dev.txt b/requirements_dev.txt index 043f303..c3c1f1b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,5 @@ -homeassistant -pre-commit -flake8 -reorder-python-imports +homeassistant==2022.11.4 +pre-commit==2.20.0 +flake8==5.0.4 +reorder-python-imports==3.9.0 +pur diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..ac80bbb --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +-r requirements_dev.txt +pytest==7.1.3 +pytest-homeassistant-custom-component==0.12.21