From c82e115dcf81e9caeb71ffe903f349b76c6bb94a Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Thu, 28 Mar 2024 13:45:48 +0100 Subject: [PATCH 01/12] fix lint error --- custom_components/siku/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/siku/coordinator.py b/custom_components/siku/coordinator.py index 37332cd..9d19f6e 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from datetime import timedelta from homeassistant.config_entries import ConfigEntry @@ -52,6 +51,6 @@ async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: try: data = await self.api.status() # self.logger.debug(data) - except (OSError, socket.timeout) as ex: + except (TimeoutError, OSError) as ex: raise UpdateFailed(f"Connection to Siku Fan failed: {ex}") from ex return data From 2b56526e7e69d25d39292f7ad455d165abdd73e4 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Thu, 28 Mar 2024 13:49:41 +0100 Subject: [PATCH 02/12] update action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f499fec..3e12387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,6 @@ jobs: zip siku-integration.zip -r ./ - name: "Upload the ZIP file to the release" - uses: softprops/action-gh-release@v0.1.15 + uses: softprops/action-gh-release@v2.0.2 with: files: ${{ github.workspace }}/custom_components/siku/siku-integration.zip From d098f9c6f96df45b5e3f67514f05ce77edcb8f33 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Thu, 28 Mar 2024 13:50:46 +0100 Subject: [PATCH 03/12] update vscode config --- .devcontainer/devcontainer.json | 16 +++++++++------- .vscode/extensions.json | 8 +++++++- .vscode/settings.json | 6 ++++++ 3 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b070be8..fa953ac 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { "name": "Siku Fan integration development", - "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11-bullseye", + "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12-bookworm", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 @@ -15,12 +15,14 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance", - "GitHub.copilot" - ], + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "GitHub.copilot", + "github.vscode-github-actions", + "charliermarsh.ruff" + ], "settings": { "files.eol": "\n", "editor.tabSize": 4, diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 174a5cc..0ca7378 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,11 @@ { "recommendations": [ - "github.copilot" + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "GitHub.copilot", + "github.vscode-github-actions", + "charliermarsh.ruff" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..06910fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff" + } +} \ No newline at end of file From 727d193d2c459bf895073e28910bd899a3767801 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Thu, 28 Mar 2024 14:59:52 +0100 Subject: [PATCH 04/12] add extra commands --- custom_components/siku/api_v1.py | 2 + custom_components/siku/api_v2.py | 63 +++++++++++++++++++++++++++- custom_components/siku/manifest.json | 8 ++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/custom_components/siku/api_v1.py b/custom_components/siku/api_v1.py index 88167e4..cf6faa5 100644 --- a/custom_components/siku/api_v1.py +++ b/custom_components/siku/api_v1.py @@ -1,4 +1,5 @@ """Helper api function for sending commands to the fan controller.""" + import logging import socket @@ -135,4 +136,5 @@ async def _translate_response(self, hexlist: list[str]) -> dict: if direction_value != DIRECTION_ALTERNATING else None, "mode": mode_value, + "version": "1", } diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index ee229a8..20a2873 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -1,4 +1,5 @@ """Helper api function for sending commands to the fan controller.""" + import logging import socket @@ -36,6 +37,23 @@ COMMAND_DIRECTION = "B7" COMMAND_DEVICE_TYPE = "B9" COMMAND_MODE = "07" +COMMAND_CURRENT_HUMIDITY = "25" +COMMAND_MANUAL_SPEED = "44" +COMMAND_FAN1RPM = "4A" +COMMAND_FILTER_TIMER = "64" +COMMAND_RESET_FILTER_TIMER = "65" +COMMAND_SEARCH = "7C" +COMMAND_RUN_HOURS = "7E" +COMMAND_RESET_ALARMS = "80" +COMMAND_READ_ALARM = "83" +# Byte 1: Firmware-Version (major) +# Byte 2: Firmware-Version (minor) +# Byte 3: Day +# Byte 4: Month +# Byte 5 and 6: Year +COMMAND_READ_FIRMWARE_VERSION = "86" +COMMAND_FILTER_ALARM = "88" +COMMAND_FAN_TYPE = "B9" COMMAND_FUNCTION_R = "01" COMMAND_FUNCTION_W = "02" @@ -69,7 +87,19 @@ def __init__(self, host: str, port: int, idnum: str, password: str) -> None: 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() + commands = [ + COMMAND_DEVICE_TYPE, + COMMAND_ON_OFF, + COMMAND_SPEED, + COMMAND_DIRECTION, + COMMAND_MODE, + COMMAND_CURRENT_HUMIDITY, + COMMAND_FAN1RPM, + COMMAND_FILTER_TIMER, + COMMAND_READ_ALARM, + COMMAND_READ_FIRMWARE_VERSION, + ] + cmd = "".join(commands).upper() hexlist = await self._send_command(FUNC_READ, cmd) data = await self._parse_response(hexlist) return await self._translate_response(data) @@ -222,12 +252,43 @@ async def _translate_response(self, data: dict) -> dict: mode = MODES[data[COMMAND_MODE]] except KeyError: mode = PRESET_MODE_AUTO + try: + humidity = int(data[COMMAND_CURRENT_HUMIDITY], 16) + except KeyError: + humidity = None + try: + rpm = int(data[COMMAND_FAN1RPM], 16) + except KeyError: + rpm = 0 + try: + filter_timer = int(data[COMMAND_FILTER_TIMER], 16) + except KeyError: + filter_timer = 0 + try: + alarm = bool(data[COMMAND_READ_ALARM] != "00") + except KeyError: + alarm = False + try: + # Byte 1: Firmware-Version (major) + # Byte 2: Firmware-Version (minor) + # Byte 3: Day + # Byte 4: Month + # Byte 5 and 6: Year + firmware = f"{int(data[COMMAND_READ_FIRMWARE_VERSION][0], 16)}.{int(data[COMMAND_READ_FIRMWARE_VERSION][1], 16)}" + except KeyError: + firmware = None return { "is_on": is_on, "speed": speed, "oscillating": oscillating, "direction": direction, "mode": mode, + "humidity": humidity, + "rpm": rpm, + "firmware": firmware, + "filter_timer": filter_timer, + "alarm": alarm, + "version": "2", } async def _parse_response(self, hexlist: list[str]) -> dict: diff --git a/custom_components/siku/manifest.json b/custom_components/siku/manifest.json index dac4663..24b285f 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -1,7 +1,9 @@ { "domain": "siku", "name": "Siku Fan", - "codeowners": ["@hmn"], + "codeowners": [ + "@hmn" + ], "config_flow": true, "dependencies": [], "documentation": "https://github.com/hmn/siku-integration", @@ -10,6 +12,6 @@ "issue_tracker": "https://github.com/hmn/siku-integration/issues", "requirements": [], "ssdp": [], - "version": "2.0.2", + "version": "2.1.0", "zeroconf": [] -} +} \ No newline at end of file From 06da1b4ac6f19fc4e0df4e5a5b8a81fe8b2f710d Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Thu, 28 Mar 2024 15:20:19 +0100 Subject: [PATCH 05/12] update actions --- .github/workflows/codeql.yml | 2 +- .github/workflows/lint.yml | 6 +++--- .github/workflows/release.yml | 2 +- .github/workflows/validate.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d3726b1..0f48d18 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8c74bde..0e97c27 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,12 +14,12 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Checkout the repository" - uses: "actions/checkout@v3.5.2" + uses: "actions/checkout@v4" - name: "Set up Python" - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: - python-version: "3.10" + python-version: "3.12" cache: "pip" - name: "Install requirements" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e12387..0164f1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: contents: write steps: - name: "Checkout the repository" - uses: "actions/checkout@v3.5.2" + uses: "actions/checkout@v4" - name: "Adjust version number" shell: "bash" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6d257aa..1ef40ce 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,7 +17,7 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Checkout the repository" - uses: "actions/checkout@v3.5.2" + uses: "actions/checkout@v4" - name: "Run hassfest validation" uses: "home-assistant/actions/hassfest@master" @@ -27,7 +27,7 @@ jobs: runs-on: "ubuntu-latest" steps: - name: "Checkout the repository" - uses: "actions/checkout@v3.5.2" + uses: "actions/checkout@v4" - name: "Run HACS validation" uses: "hacs/action@main" From ad645014bd267a9ef7c4f7d4defe21a6a96d68d6 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Fri, 29 Mar 2024 15:47:15 +0100 Subject: [PATCH 06/12] add sensor and update --- custom_components/siku/__init__.py | 3 +- custom_components/siku/api_v2.py | 11 +- custom_components/siku/config_flow.py | 5 +- custom_components/siku/coordinator.py | 46 ++++++-- custom_components/siku/fan.py | 67 ++++++++---- custom_components/siku/manifest.json | 1 + custom_components/siku/sensor.py | 149 ++++++++++++++++++++++++++ scripts/setup | 5 + 8 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 custom_components/siku/sensor.py diff --git a/custom_components/siku/__init__.py b/custom_components/siku/__init__.py index 982c755..7ff82ab 100644 --- a/custom_components/siku/__init__.py +++ b/custom_components/siku/__init__.py @@ -1,4 +1,5 @@ """The Siku Fan integration.""" + from __future__ import annotations from homeassistant.config_entries import ConfigEntry @@ -9,7 +10,7 @@ from .const import DOMAIN from .coordinator import SikuDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.FAN] +PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index 20a2873..da381ad 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -40,6 +40,9 @@ COMMAND_CURRENT_HUMIDITY = "25" COMMAND_MANUAL_SPEED = "44" COMMAND_FAN1RPM = "4A" +# Byte 1: Minutes (0...59) +# Byte 2: Hours (0...23) +# Byte 3: Days (0...181) COMMAND_FILTER_TIMER = "64" COMMAND_RESET_FILTER_TIMER = "65" COMMAND_SEARCH = "7C" @@ -261,7 +264,13 @@ async def _translate_response(self, data: dict) -> dict: except KeyError: rpm = 0 try: - filter_timer = int(data[COMMAND_FILTER_TIMER], 16) + # Byte 1: Minutes (0...59) + # Byte 2: Hours (0...23) + # Byte 3: Days (0...181) + minutes = 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) + filter_timer = int(minutes + hours * 60 + days * 24 * 60) except KeyError: filter_timer = 0 try: diff --git a/custom_components/siku/config_flow.py b/custom_components/siku/config_flow.py index 4a0d09e..ed5b67c 100644 --- a/custom_components/siku/config_flow.py +++ b/custom_components/siku/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Siku Fan integration.""" + from __future__ import annotations import logging @@ -41,10 +42,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """ if data[CONF_VERSION] == 1: api = SikuV1Api(data[CONF_IP_ADDRESS], data[CONF_PORT]) - else: + elif data[CONF_VERSION] == 2: api = SikuV2Api( data[CONF_IP_ADDRESS], data[CONF_PORT], data[CONF_ID], data[CONF_PASSWORD] ) + else: + raise ValueError("Invalid API version") if not await api.status(): raise CannotConnect diff --git a/custom_components/siku/coordinator.py b/custom_components/siku/coordinator.py index 9d19f6e..5105c8a 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -1,4 +1,5 @@ """Data update coordinator for the Deluge integration.""" + from __future__ import annotations import logging @@ -10,13 +11,16 @@ from homeassistant.const import CONF_PORT from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed from .api_v1 import SikuV1Api from .api_v2 import SikuV2Api -from .const import CONF_ID +from .const import CONF_ID, DEFAULT_MODEL, DEFAULT_NAME from .const import CONF_VERSION +from .const import DOMAIN +from .const import DEFAULT_MANUFACTURER LOGGER = logging.getLogger(__name__) @@ -28,13 +32,8 @@ class SikuDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the coordinator.""" - super().__init__( - hass=hass, - logger=LOGGER, - name=entry.title, - update_interval=timedelta(seconds=30), - ) self.config_entry = entry + if entry.data[CONF_VERSION] == 1: self.api = SikuV1Api(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]) else: @@ -44,6 +43,24 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: entry.data[CONF_ID], entry.data[CONF_PASSWORD], ) + name = f"{DEFAULT_NAME} {entry.data[CONF_IP_ADDRESS]}" + + super().__init__( + hass=hass, + logger=LOGGER, + name=name, + update_interval=timedelta(seconds=30), + ) + + @property + def device_info(self) -> DeviceInfo: + """Return the DeviceInfo of this Siku fan using IP as identifier.""" + return DeviceInfo( + identifiers={(DOMAIN, f"{self.api.host}:{self.api.port}")}, + model=DEFAULT_MODEL, + manufacturer=DEFAULT_MANUFACTURER, + name=self.name or f"{DEFAULT_NAME} {self.api.host}", + ) async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: """Get the latest data from Siku fan and updates the state.""" @@ -51,6 +68,21 @@ async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: try: data = await self.api.status() # self.logger.debug(data) + # TODO: add better test options + # TEST + # data = { + # "is_on": False, + # "speed": "00", + # "oscillating": False, + # "direction": None, + # "mode": PRESET_MODE_AUTO, + # "humidity": 50, + # "rpm": 1000, + # "firmware": "0.0", + # "filter_timer": 10, + # "alarm": False, + # "version": "2", + # } except (TimeoutError, OSError) as 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 47d8756..a465c5e 100644 --- a/custom_components/siku/fan.py +++ b/custom_components/siku/fan.py @@ -1,4 +1,5 @@ """Demo fan platform that has a fake fan.""" + from __future__ import annotations import logging @@ -25,9 +26,6 @@ LOGGER = logging.getLogger(__name__) -# percentage = ordered_list_item_to_percentage(FAN_SPEEDS, "01") -# named_speed = percentage_to_ordered_list_item(FAN_SPEEDS, 33) - async def async_setup_entry( hass: HomeAssistant, @@ -35,15 +33,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Siku fan.""" + LOGGER.debug("Setting up Siku fan") + LOGGER.debug("Entry: %s", entry.entry_id) + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ SikuFan( - hass, - hass.data[DOMAIN][entry.entry_id], - f"{entry.entry_id}", - DEFAULT_NAME, + hass=hass, + coordinator=coordinator, + # entry=entry, + # unique_id=f"{entry.entry_id}", + # name=f"{DEFAULT_NAME} {entry.data[CONF_IP_ADDRESS]}", ) - ] + ], + True, ) @@ -62,29 +65,50 @@ class SikuFan(SikuEntity, FanEntity): PRESET_MODE_PARTY, PRESET_MODE_SLEEP, ] - _attr_has_entity_name = True - _attr_name = None _attr_should_poll = True def __init__( self, hass: HomeAssistant, coordinator: SikuDataUpdateCoordinator, - unique_id: str, - name: str, + # entry: ConfigEntry, + # unique_id: str, + # name: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.hass = hass - self._unique_id = unique_id - if name is None: - name = {DEFAULT_NAME} - self._attr_name = f"{name} {coordinator.api.host}" - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id + # self._unique_id = unique_id + # if name is None: + # name = {DEFAULT_NAME} + + # self._attr_name = f"{name} {coordinator.api.host}" + # self._attr_unique_id = entry.unique_id or entry.entry_id + + # self._attr_device_info = DeviceInfo( + # identifiers={(DOMAIN, self._attr_unique_id)}, + # via_device=(DOMAIN, entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]), + # manufacturer=DEFAULT_MANUFACTURER, + # name=self._attr_name, + # ) + self._attr_name = f"{DEFAULT_NAME} {coordinator.api.host}" + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-fan" + ) + + # self._attr_device_info = DeviceInfo( + # identifiers={(DOMAIN, f"{coordinator.api.host}:{coordinator.api.port}")}, + # name=coordinator.name, + # # entry_type=DeviceEntryType.SERVICE, + # ) + + # @property + # def unique_id(self) -> str: + # """Return the unique id.""" + # return self._unique_id @property def speed_count(self) -> int: @@ -161,8 +185,7 @@ async def async_turn_on( ) -> None: """Turn on the entity.""" if percentage is None: - percentage = ordered_list_item_to_percentage( - FAN_SPEEDS, FAN_SPEEDS[0]) + percentage = ordered_list_item_to_percentage(FAN_SPEEDS, FAN_SPEEDS[0]) await self.async_set_percentage(percentage) self.async_write_ha_state() diff --git a/custom_components/siku/manifest.json b/custom_components/siku/manifest.json index 24b285f..aa1ea62 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -13,5 +13,6 @@ "requirements": [], "ssdp": [], "version": "2.1.0", + "dhcp": [], "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/siku/sensor.py b/custom_components/siku/sensor.py new file mode 100644 index 0000000..02c7012 --- /dev/null +++ b/custom_components/siku/sensor.py @@ -0,0 +1,149 @@ +"""Support for MelCloud device sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +import logging +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, + UnitOfTime, + REVOLUTIONS_PER_MINUTE, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SikuEntity +from .const import DOMAIN +from .coordinator import SikuDataUpdateCoordinator + +LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class SikuRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float] + enabled: Callable[[Any], bool] + + +@dataclasses.dataclass(frozen=True) +class SikuSensorEntityDescription(SensorEntityDescription, SikuRequiredKeysMixin): + """Describes Siku fan sensor entity.""" + + +SENSORS: tuple[SikuSensorEntityDescription, ...] = ( + SensorEntityDescription( + key="version", + translation_key="version", + icon="mdi:information", + name="Version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="rpm", + name="RPM", + icon="mdi:rotate-right", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="firmware", + name="Firmware version", + icon="mdi:information", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="alarm", + name="Alarm", + icon="mdi:alarm-light", + ), + SensorEntityDescription( + key="filter_timer", + name="Filter timer countdown", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Siku fan sensors.""" + LOGGER.debug("Setting up Siku fan sensors %s", entry.entry_id) + coordinator = hass.data[DOMAIN][entry.entry_id] + available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()} + + entities: list[SikuSensor] = [ + SikuSensor(hass, coordinator, description) + for description in SENSORS + if description.key in available_resources + ] + + async_add_entities(entities, True) + + +class SikuSensor(SikuEntity, SensorEntity): + """Representation of a Sensor.""" + + _attr_should_poll = True + + def __init__( + self, + hass: HomeAssistant, + coordinator: SikuDataUpdateCoordinator, + description: SensorEntityDescription, + # entry: ConfigEntry, + # unique_id: str, + # name: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator=coordinator, context=description.key) + self.hass = hass + + self.entity_description = description + LOGGER.debug("Sensor description: %s", description) + LOGGER.debug("Sensor coordinator: %s", coordinator) + LOGGER.debug("Sensor device: %s", coordinator.device_info) + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-{description.key}" + ) + + # Initial update of attributes. + # self._update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() + + def _update_attrs(self) -> None: + """Update sensor attributes based on coordinator data.""" + key = self.entity_description.key + LOGGER.debug("Handling update for %s : %s", key, self.coordinator.data[key]) + self._attr_native_value = self.coordinator.data[key] diff --git a/scripts/setup b/scripts/setup index 141d19f..a66b4a3 100755 --- a/scripts/setup +++ b/scripts/setup @@ -4,4 +4,9 @@ set -e cd "$(dirname "$0")/.." +# Install packages +apt-get update +apt-get install -y libpcap-dev ffmpeg + +# Install dependencies python3 -m pip install --requirement requirements.txt From af56360647789eaeb6e1a81c5be2ff42f76549db Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Fri, 29 Mar 2024 15:51:27 +0100 Subject: [PATCH 07/12] fix manifest lint error --- 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 aa1ea62..5ed77cd 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -6,6 +6,7 @@ ], "config_flow": true, "dependencies": [], + "dhcp": [], "documentation": "https://github.com/hmn/siku-integration", "homekit": {}, "iot_class": "local_polling", @@ -13,6 +14,5 @@ "requirements": [], "ssdp": [], "version": "2.1.0", - "dhcp": [], "zeroconf": [] } \ No newline at end of file From d81d51a18e7dbb70d035f26bfdd14a7822d3aa96 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Fri, 29 Mar 2024 15:53:44 +0100 Subject: [PATCH 08/12] update ruff lint config --- .ruff.toml | 68 +++++++++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 7a8331a..f5e20e5 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,47 +2,47 @@ target-version = "py310" -select = [ - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "C", # complexity - "D", # docstrings - "E", # pycodestyle - "F", # pyflakes/autoflake - "ICN001", # import concentions; {name} should be imported as {asname} +lint.select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} "PGH004", # Use specific rule codes when using noqa "PLC0414", # Useless import alias. Import alias does not rename original package. - "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass - "SIM117", # Merge with-statements that use the same scope - "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() - "SIM201", # Use {left} != {right} instead of not {left} == {right} - "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} - "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. - "SIM401", # Use get from dict with default instead of an if block - "T20", # flake8-print - "TRY004", # Prefer TypeError exception for invalid type - "RUF006", # Store a reference to the return value of asyncio.create_task - "UP", # pyupgrade - "W", # pycodestyle + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle ] -ignore = [ - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D404", # First word of the docstring should not be This - "D406", # Section name should end with a newline - "D407", # Section name underlining - "D411", # Missing blank line before section - "E501", # line too long - "E731", # do not assign a lambda expression, use a def +lint.ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def ] -[flake8-pytest-style] +[lint.flake8-pytest-style] fixture-parentheses = false -[pyupgrade] +[lint.pyupgrade] keep-runtime-typing = true -[mccabe] -max-complexity = 25 \ No newline at end of file +[lint.mccabe] +max-complexity = 25 From d7d6f5a7d39e7f9d0af343b108fc7aad56e76c8c Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 30 Mar 2024 20:41:13 +0100 Subject: [PATCH 09/12] added link for twinfresh fans --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 313de46..348c2f7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The fan is sold under different brands, for instance : - [SIKU RV/Twinfresh](https://www.siku.at/produkte/) - [DUKA One](https://dukaventilation.dk/produkter/1-rums-ventilationsloesninger) - [Oxxify](https://raumluft-shop.de/lueftung/dezentrale-lueftungsanlage-mit-waermerueckgewinnung/oxxify.html) +- [Twinfresh](https://twinfresh.no) ## Installation From 8956b2b14d9f599d9954a2ff8fb556edeadee7f0 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 30 Mar 2024 20:41:28 +0100 Subject: [PATCH 10/12] cleanup deprecated settings --- .devcontainer/devcontainer.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fa953ac..7c99b7d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -28,8 +28,6 @@ "editor.tabSize": 4, "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, From 5a79ea7cd0afe4eedd8792c7fadcf6eb10f30538 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 30 Mar 2024 20:43:08 +0100 Subject: [PATCH 11/12] added some sensors and changed format --- custom_components/siku/api_v2.py | 7 ++++ custom_components/siku/coordinator.py | 17 +++++++--- custom_components/siku/sensor.py | 49 ++++++++++++--------------- scripts/setup | 4 +-- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/custom_components/siku/api_v2.py b/custom_components/siku/api_v2.py index da381ad..e03b49c 100644 --- a/custom_components/siku/api_v2.py +++ b/custom_components/siku/api_v2.py @@ -36,6 +36,7 @@ COMMAND_SPEED = "02" COMMAND_DIRECTION = "B7" COMMAND_DEVICE_TYPE = "B9" +COMMAND_BOOST = "06" COMMAND_MODE = "07" COMMAND_CURRENT_HUMIDITY = "25" COMMAND_MANUAL_SPEED = "44" @@ -95,6 +96,7 @@ async def status(self) -> dict: COMMAND_ON_OFF, COMMAND_SPEED, COMMAND_DIRECTION, + COMMAND_BOOST, COMMAND_MODE, COMMAND_CURRENT_HUMIDITY, COMMAND_FAN1RPM, @@ -251,6 +253,10 @@ async def _translate_response(self, data: dict) -> dict: except KeyError: direction = None oscillating = True + try: + boost = bool(data[COMMAND_BOOST] != "00") + except KeyError: + boost = False try: mode = MODES[data[COMMAND_MODE]] except KeyError: @@ -291,6 +297,7 @@ async def _translate_response(self, data: dict) -> dict: "speed": speed, "oscillating": oscillating, "direction": direction, + "boost": boost, "mode": mode, "humidity": humidity, "rpm": rpm, diff --git a/custom_components/siku/coordinator.py b/custom_components/siku/coordinator.py index 5105c8a..8eac41e 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -4,6 +4,7 @@ import logging from datetime import timedelta +from random import randint from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS @@ -17,7 +18,13 @@ from .api_v1 import SikuV1Api from .api_v2 import SikuV2Api -from .const import CONF_ID, DEFAULT_MODEL, DEFAULT_NAME +from .const import ( + CONF_ID, + DEFAULT_MODEL, + DEFAULT_NAME, + PRESET_MODE_AUTO, + PRESET_MODE_PARTY, +) from .const import CONF_VERSION from .const import DOMAIN from .const import DEFAULT_MANUFACTURER @@ -75,11 +82,11 @@ async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: # "speed": "00", # "oscillating": False, # "direction": None, - # "mode": PRESET_MODE_AUTO, - # "humidity": 50, - # "rpm": 1000, + # "mode": PRESET_MODE_PARTY, + # "humidity": 50 + randint(0, 50), + # "rpm": 1000 + randint(0, 1000), # "firmware": "0.0", - # "filter_timer": 10, + # "filter_timer": 1440 * 30 + 65, # "alarm": False, # "version": "2", # } diff --git a/custom_components/siku/sensor.py b/custom_components/siku/sensor.py index 02c7012..8e2051c 100644 --- a/custom_components/siku/sensor.py +++ b/custom_components/siku/sensor.py @@ -31,57 +31,62 @@ @dataclasses.dataclass(frozen=True) -class SikuRequiredKeysMixin: - """Mixin for required keys.""" - - value_fn: Callable[[Any], float] - enabled: Callable[[Any], bool] - - -@dataclasses.dataclass(frozen=True) -class SikuSensorEntityDescription(SensorEntityDescription, SikuRequiredKeysMixin): +class SikuSensorEntityDescription(SensorEntityDescription): """Describes Siku fan sensor entity.""" SENSORS: tuple[SikuSensorEntityDescription, ...] = ( - SensorEntityDescription( + SikuSensorEntityDescription( key="version", translation_key="version", icon="mdi:information", name="Version", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + SikuSensorEntityDescription( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SikuSensorEntityDescription( key="rpm", name="RPM", icon="mdi:rotate-right", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SikuSensorEntityDescription( key="firmware", name="Firmware version", icon="mdi:information", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + SikuSensorEntityDescription( key="alarm", name="Alarm", icon="mdi:alarm-light", ), - SensorEntityDescription( + SikuSensorEntityDescription( key="filter_timer", name="Filter timer countdown", + icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, + ), + SikuSensorEntityDescription( + key="boost", + name="Boost mode", + icon="msi:speedometer", + ), + SikuSensorEntityDescription( + key="mode", + name="Mode", + icon="mdi:fan-auto", ), ) @@ -115,27 +120,17 @@ def __init__( hass: HomeAssistant, coordinator: SikuDataUpdateCoordinator, description: SensorEntityDescription, - # entry: ConfigEntry, - # unique_id: str, - # name: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator=coordinator, context=description.key) self.hass = hass self.entity_description = description - LOGGER.debug("Sensor description: %s", description) - LOGGER.debug("Sensor coordinator: %s", coordinator) - LOGGER.debug("Sensor device: %s", coordinator.device_info) - self._attr_device_info = coordinator.device_info self._attr_unique_id = ( f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-{description.key}" ) - # Initial update of attributes. - # self._update_attrs() - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -145,5 +140,5 @@ def _handle_coordinator_update(self) -> None: def _update_attrs(self) -> None: """Update sensor attributes based on coordinator data.""" key = self.entity_description.key - LOGGER.debug("Handling update for %s : %s", key, self.coordinator.data[key]) self._attr_native_value = self.coordinator.data[key] + LOGGER.debug("Native value [%s]: %s", key, self._attr_native_value) diff --git a/scripts/setup b/scripts/setup index a66b4a3..3121abd 100755 --- a/scripts/setup +++ b/scripts/setup @@ -5,8 +5,8 @@ set -e cd "$(dirname "$0")/.." # Install packages -apt-get update -apt-get install -y libpcap-dev ffmpeg +sudo apt-get update +sudo apt-get install -y libpcap-dev ffmpeg # Install dependencies python3 -m pip install --requirement requirements.txt From 069cde85dad6fb260e7ee2a9d61cc58ddc38475c Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sat, 30 Mar 2024 20:45:08 +0100 Subject: [PATCH 12/12] fixed lint errors --- custom_components/siku/coordinator.py | 3 --- custom_components/siku/sensor.py | 2 -- 2 files changed, 5 deletions(-) diff --git a/custom_components/siku/coordinator.py b/custom_components/siku/coordinator.py index 8eac41e..1d29f59 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -4,7 +4,6 @@ import logging from datetime import timedelta -from random import randint from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS @@ -22,8 +21,6 @@ CONF_ID, DEFAULT_MODEL, DEFAULT_NAME, - PRESET_MODE_AUTO, - PRESET_MODE_PARTY, ) from .const import CONF_VERSION from .const import DOMAIN diff --git a/custom_components/siku/sensor.py b/custom_components/siku/sensor.py index 8e2051c..646c1e8 100644 --- a/custom_components/siku/sensor.py +++ b/custom_components/siku/sensor.py @@ -2,10 +2,8 @@ from __future__ import annotations -from collections.abc import Callable import dataclasses import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass,