diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b070be8..7c99b7d 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,19 +15,19 @@ "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, "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, 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 f499fec..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" @@ -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 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" 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 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 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 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_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..e03b49c 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 @@ -35,7 +36,28 @@ COMMAND_SPEED = "02" COMMAND_DIRECTION = "B7" COMMAND_DEVICE_TYPE = "B9" +COMMAND_BOOST = "06" COMMAND_MODE = "07" +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" +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 +91,20 @@ 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_BOOST, + 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) @@ -218,16 +253,58 @@ 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: 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: + # 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: + 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, + "boost": boost, "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/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 37332cd..1d29f59 100644 --- a/custom_components/siku/coordinator.py +++ b/custom_components/siku/coordinator.py @@ -1,8 +1,8 @@ """Data update coordinator for the Deluge integration.""" + from __future__ import annotations import logging -import socket from datetime import timedelta from homeassistant.config_entries import ConfigEntry @@ -11,13 +11,20 @@ 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__) @@ -29,13 +36,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: @@ -45,6 +47,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.""" @@ -52,6 +72,21 @@ 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: + # TODO: add better test options + # TEST + # data = { + # "is_on": False, + # "speed": "00", + # "oscillating": False, + # "direction": None, + # "mode": PRESET_MODE_PARTY, + # "humidity": 50 + randint(0, 50), + # "rpm": 1000 + randint(0, 1000), + # "firmware": "0.0", + # "filter_timer": 1440 * 30 + 65, + # "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 dac4663..5ed77cd 100644 --- a/custom_components/siku/manifest.json +++ b/custom_components/siku/manifest.json @@ -1,15 +1,18 @@ { "domain": "siku", "name": "Siku Fan", - "codeowners": ["@hmn"], + "codeowners": [ + "@hmn" + ], "config_flow": true, "dependencies": [], + "dhcp": [], "documentation": "https://github.com/hmn/siku-integration", "homekit": {}, "iot_class": "local_polling", "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 diff --git a/custom_components/siku/sensor.py b/custom_components/siku/sensor.py new file mode 100644 index 0000000..646c1e8 --- /dev/null +++ b/custom_components/siku/sensor.py @@ -0,0 +1,142 @@ +"""Support for MelCloud device sensors.""" + +from __future__ import annotations + +import dataclasses +import logging + +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 SikuSensorEntityDescription(SensorEntityDescription): + """Describes Siku fan sensor entity.""" + + +SENSORS: tuple[SikuSensorEntityDescription, ...] = ( + SikuSensorEntityDescription( + key="version", + translation_key="version", + icon="mdi:information", + name="Version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SikuSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + SikuSensorEntityDescription( + key="rpm", + name="RPM", + icon="mdi:rotate-right", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + ), + SikuSensorEntityDescription( + key="firmware", + name="Firmware version", + icon="mdi:information", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SikuSensorEntityDescription( + key="alarm", + name="Alarm", + icon="mdi:alarm-light", + ), + 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.TOTAL, + ), + SikuSensorEntityDescription( + key="boost", + name="Boost mode", + icon="msi:speedometer", + ), + SikuSensorEntityDescription( + key="mode", + name="Mode", + icon="mdi:fan-auto", + ), +) + + +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, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator=coordinator, context=description.key) + self.hass = hass + + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-{description.key}" + ) + + @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 + 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 141d19f..3121abd 100755 --- a/scripts/setup +++ b/scripts/setup @@ -4,4 +4,9 @@ set -e cd "$(dirname "$0")/.." +# Install packages +sudo apt-get update +sudo apt-get install -y libpcap-dev ffmpeg + +# Install dependencies python3 -m pip install --requirement requirements.txt