├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── dependabot.yml └── workflows │ ├── test.yml │ ├── lint.yml │ ├── release.yml │ ├── validate.yml │ └── codeql.yml ├── tests ├── __init__.py ├── test_fan.py ├── test_sensor.py ├── conftest.py ├── test_button.py ├── test_coordinator.py ├── test_config_flow.py ├── test_api_v1.py └── test_api_v2.py ├── custom_components ├── __init__.py └── siku │ ├── manifest.json │ ├── const.py │ ├── translations │ ├── en.json │ └── da.json │ ├── strings.json │ ├── __init__.py │ ├── button.py │ ├── coordinator.py │ ├── udp.py │ ├── sensor.py │ ├── config_flow.py │ ├── fan.py │ ├── api_v2.py │ └── api_v1.py ├── scripts ├── lint ├── setup ├── develop └── develop-watch ├── pytest.ini ├── hacs.json ├── requirements.txt ├── config └── configuration.yaml ├── .gitignore ├── .vscode ├── settings.json ├── extensions.json └── tasks.json ├── LICENSE ├── .devcontainer └── devcontainer.json ├── .ruff.toml ├── fake_fan ├── send_packet.py ├── test_fake_fan.py ├── README.md ├── TESTING_GUIDE.md └── fake_fan_server.py ├── CONTRIBUTING.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Siku integration.""" 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /tests/test_fan.py: -------------------------------------------------------------------------------- 1 | """Tests for SikuFan entity.""" 2 | 3 | 4 | # ruff: noqa: D103 5 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | ruff check . --fix -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for SikuSensor entity.""" 2 | 3 | 4 | # ruff: noqa: D103 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 6.0 3 | addopts = -ra -q 4 | testpaths = "tests" 5 | asyncio_mode = auto 6 | asyncio_default_fixture_loop_scope = function -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Siku Fan", 3 | "filename": "siku-integration.zip", 4 | "hide_default_branch": true, 5 | "render_readme": true, 6 | "zip_release": true 7 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog 2 | homeassistant 3 | pip 4 | ruff 5 | pytest 6 | pytest-homeassistant-custom-component 7 | pytest-asyncio 8 | watchfiles 9 | git+https://github.com/boto/botocore -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.siku: debug 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | # misc 9 | .coverage 10 | coverage.xml 11 | pythonenv* 12 | .python-version 13 | venv 14 | .venv 15 | .DS_Store 16 | 17 | # Home Assistant configuration 18 | config/* 19 | !config/configuration.yaml 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "charliermarsh.ruff" 5 | }, 6 | "python.analysis.typeCheckingMode": "standard", 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "python.testing.pytestArgs": [ 10 | "tests" 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "ms-python.vscode-pylance", 5 | "github.vscode-pull-request-github", 6 | "github.vscode-github-actions", 7 | "ryanluker.vscode-coverage-gutters", 8 | "charliermarsh.ruff", 9 | "GitHub.copilot", 10 | "github.copilot-chat" 11 | } -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Install packages 6 | sudo apt-get update 7 | sudo apt-get install -y libpcap-dev ffmpeg libturbojpeg0 8 | 9 | # Install dependencies 10 | which python3 11 | which pip3 12 | python3 --version 13 | pip3 --version 14 | python3 -m pip install --requirement requirements.txt 15 | 16 | # change away from the script directory 17 | cd "$(dirname "$0")/.." 18 | -------------------------------------------------------------------------------- /custom_components/siku/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "siku", 3 | "name": "Siku (Blauberg) Fan", 4 | "codeowners": [ 5 | "@hmn" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "dhcp": [], 10 | "documentation": "https://github.com/hmn/siku-integration", 11 | "homekit": {}, 12 | "iot_class": "local_polling", 13 | "issue_tracker": "https://github.com/hmn/siku-integration/issues", 14 | "requirements": [], 15 | "ssdp": [], 16 | "version": "2.2.6", 17 | "zeroconf": [] 18 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/siku 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global config for pytest.""" 2 | 3 | import pytest 4 | 5 | from pytest_homeassistant_custom_component.syrupy import HomeAssistantSnapshotExtension 6 | from syrupy.assertion import SnapshotAssertion 7 | 8 | 9 | @pytest.fixture 10 | def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: 11 | """Return snapshot assertion fixture with the Home Assistant extension.""" 12 | return snapshot.use_extension(HomeAssistantSnapshotExtension) 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def auto_enable_custom_integrations(enable_custom_integrations): 17 | """Enable custom integrations for all tests.""" 18 | yield 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | pytest: 13 | name: "Pytest" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v6" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v6.1.0 21 | with: 22 | python-version: "3.13" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run tests" 29 | run: pytest 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v6" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v6.1.0 21 | with: 22 | python-version: "3.13" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /custom_components/siku/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Siku Fan integration.""" 2 | 3 | DOMAIN = "siku" 4 | DEFAULT_MANUFACTURER = "Siku" 5 | DEFAULT_MODEL = "RV" 6 | DEFAULT_NAME = "Siku Fan" 7 | DEFAULT_PORT = 4000 8 | DEFAULT_IDNUM = "DEFAULT_DEVICEID" 9 | DEFAULT_PASSWORD = "1111" 10 | 11 | CONF_VERSION = "version" 12 | CONF_ID = "idnum" 13 | 14 | FAN_SPEEDS = ["01", "02", "03"] 15 | DIRECTION_FORWARD = "00" 16 | DIRECTION_ALTERNATING = "01" 17 | DIRECTION_REVERSE = "02" 18 | DIRECTIONS = { 19 | DIRECTION_FORWARD: "forward", 20 | DIRECTION_ALTERNATING: "alternating", 21 | DIRECTION_REVERSE: "reverse", 22 | } 23 | 24 | PRESET_MODE_AUTO = "auto" 25 | PRESET_MODE_ON = "on" 26 | PRESET_MODE_PARTY = "party" 27 | PRESET_MODE_SLEEP = "sleep" 28 | PRESET_MODE_MANUAL = "manual" 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [], 9 | "isBackground": true 10 | }, 11 | { 12 | "label": "Run Home Assistant with Auto-Reload", 13 | "type": "shell", 14 | "command": "scripts/develop-watch", 15 | "problemMatcher": [], 16 | "isBackground": true 17 | }, 18 | { 19 | "label": "Lint using ruff", 20 | "type": "shell", 21 | "command": "scripts/lint", 22 | "problemMatcher": [] 23 | }, 24 | { 25 | "label": "Test using pytest", 26 | "type": "shell", 27 | "command": "pytest", 28 | "problemMatcher": [] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /custom_components/siku/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "value_error": "Incorrect values defined in form", 8 | "timeout_error": "Timeout error, failed to get status", 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "invalid_idnum": "Invalid authentication, idnum must be 16 chars and can be found on the fan, on some models DEFAULT_DEVICEID can be used", 12 | "invalid_password": "Invalid authentication, password max size is 8 chars and default is 1111", 13 | "unknown": "Unexpected error" 14 | }, 15 | "step": { 16 | "user": { 17 | "data": { 18 | "ip_address": "IP address", 19 | "port": "Port", 20 | "version": "Hardware version", 21 | "idnum": "Fan ID (only v2)", 22 | "password": "Fan password (only v2)" 23 | } 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /custom_components/siku/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Enheden er allerede konfigureret" 5 | }, 6 | "error": { 7 | "value_error": "Værdierne i formen er forkerte", 8 | "timeout_error": "Timeout-fejl, kunne ikke hente status", 9 | "cannot_connect": "Kunne ikke forbinde", 10 | "invalid_auth": "Ugyldig godkendelse", 11 | "invalid_idnum": "Ugyldig godkendelse, idnum skal være 16 tegn og kan findes på enheden, på nogle modeller kan DEFAULT_DEVICEID bruges", 12 | "invalid_password": "Ugyldig godkendelse, kodeordet kan maksimalt være 8 tegn og standarden er 1111", 13 | "unknown": "Uventet fejl" 14 | }, 15 | "step": { 16 | "user": { 17 | "data": { 18 | "ip_address": "IP-adresse", 19 | "port": "Port", 20 | "version": "Hardware version", 21 | "idnum": "Blæser ID (kun v2)", 22 | "password": "Blæser kodeord (kun v2)" 23 | } 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /custom_components/siku/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "ip_address": "[%key:common::config_flow::data::ip_address%]", 7 | "port": "[%key:common::config_flow::data::port%]", 8 | "version": "[%key:common::config_flow::data::version%]", 9 | "idnum": "[%key:common::config_flow::data::idnum%]", 10 | "password": "[%key:common::config_flow::data::password%]" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "value_error": "[%key:common::config_flow::error::value_error%]", 16 | "timeout_error": "[%key:common::config_flow::error::timeout_error%]", 17 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 18 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 19 | "unknown": "[%key:common::config_flow::error::unknown%]" 20 | }, 21 | "abort": { 22 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scripts/develop-watch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Colors for output 8 | RED='\033[0;31m' 9 | GREEN='\033[0;32m' 10 | YELLOW='\033[1;33m' 11 | NC='\033[0m' # No Color 12 | 13 | echo -e "${GREEN}Starting Home Assistant with auto-reload...${NC}" 14 | echo -e "${YELLOW}Watching: custom_components/siku/**/*.py${NC}" 15 | echo -e "${YELLOW}Press Ctrl+C to stop${NC}" 16 | echo "" 17 | 18 | # Create config dir if not present 19 | if [[ ! -d "${PWD}/config" ]]; then 20 | mkdir -p "${PWD}/config" 21 | hass --config "${PWD}/config" --script ensure_config 22 | fi 23 | 24 | # Set the path to custom_components 25 | ## This let's us have the structure we want /custom_components/siku 26 | ## while at the same time have Home Assistant configuration inside /config 27 | ## without resulting to symlinks. 28 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 29 | 30 | # Use watchfiles to monitor changes and restart Home Assistant 31 | watchfiles \ 32 | --filter python \ 33 | "hass --config \"${PWD}/config\" --debug" \ 34 | custom_components/siku 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v6" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/siku/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/siku" 30 | zip siku-integration.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v2.5.0 34 | with: 35 | files: ${{ github.workspace }}/custom_components/siku/siku-integration.zip 36 | tag_name: ${{ github.event.release.tag_name }} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 hmn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v6" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v6" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands 37 | ignore: "brands" 38 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "name": "Siku Fan integration development", 4 | "image": "mcr.microsoft.com/vscode/devcontainers/python:3.13-bookworm", 5 | "postCreateCommand": "bash scripts/setup", 6 | "waitFor": "postCreateCommand", 7 | "forwardPorts": [8123, 4000], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | }, 13 | "4000": { 14 | "label": "Fake Siku Fan", 15 | "onAutoForward": "notify" 16 | } 17 | }, 18 | "customizations": { 19 | "vscode": { 20 | "extensions": [ 21 | "ms-python.python", 22 | "ms-python.vscode-pylance", 23 | "github.vscode-pull-request-github", 24 | "github.vscode-github-actions", 25 | "ryanluker.vscode-coverage-gutters", 26 | "charliermarsh.ruff", 27 | "GitHub.copilot", 28 | "github.copilot-chat" 29 | ], 30 | "settings": { 31 | "terminal.integrated.profiles.linux": { 32 | "zsh": { 33 | "path": "/usr/bin/zsh" 34 | } 35 | }, 36 | "files.eol": "\n", 37 | "editor.tabSize": 4, 38 | "python.pythonPath": "/usr/local/bin/python3", 39 | // "python.analysis.nodeExecutable": "auto", 40 | "python.analysis.autoSearchPaths": true, 41 | "python.analysis.typeCheckingMode": "standard", 42 | "editor.formatOnPaste": false, 43 | "editor.formatOnSave": true, 44 | "editor.formatOnType": true, 45 | "files.trimTrailingWhitespace": true 46 | } 47 | } 48 | }, 49 | "remoteUser": "vscode", 50 | "containerEnv": { 51 | "NODE_OPTIONS": "--max-old-space-size=8192" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/hmn/siku-integration/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /tests/test_button.py: -------------------------------------------------------------------------------- 1 | """Tests for SikuButton entity.""" 2 | 3 | import pytest 4 | from unittest.mock import MagicMock, Mock 5 | import custom_components.siku.button as button 6 | 7 | # ruff: noqa: D103 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_async_setup_entry_adds_entities(): 12 | """Test that async_setup_entry adds SikuButton entities with correct descriptions.""" 13 | hass = MagicMock() 14 | entry = MagicMock() 15 | entry.entry_id = "test_entry" 16 | coordinator = MagicMock() 17 | hass.data = {button.DOMAIN: {entry.entry_id: coordinator}} 18 | async_add_entities = Mock() 19 | 20 | await button.async_setup_entry(hass, entry, async_add_entities) 21 | 22 | # Check that async_add_entities was called once 23 | async_add_entities.assert_called_once() 24 | # Fix: Await the async_add_entities mock to avoid RuntimeWarning 25 | if async_add_entities.await_count == 0: 26 | await async_add_entities.async_mock() # Await the mock if not already awaited 27 | entities = async_add_entities.call_args[0][0] 28 | update = async_add_entities.call_args[0][1] 29 | assert update is True 30 | # There should be as many entities as BUTTONS 31 | assert len(entities) == len(button.BUTTONS) 32 | # All entities should be SikuButton and have correct descriptions 33 | for entity, desc in zip(entities, button.BUTTONS): 34 | assert isinstance(entity, button.SikuButton) 35 | assert entity.entity_description == desc 36 | assert entity.coordinator == coordinator 37 | assert entity.hass == hass 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_async_setup_entry_logs_debug(caplog): 42 | """Test that async_setup_entry logs debug message.""" 43 | hass = MagicMock() 44 | entry = MagicMock() 45 | entry.entry_id = "test_entry" 46 | coordinator = MagicMock() 47 | hass.data = {button.DOMAIN: {entry.entry_id: coordinator}} 48 | async_add_entities = Mock() 49 | 50 | with caplog.at_level("DEBUG"): 51 | await button.async_setup_entry(hass, entry, async_add_entities) 52 | assert f"Setting up Siku fan buttons {entry.entry_id}" in caplog.text 53 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [lint.pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io//more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of currently [previous issues](https://github.com/hmn/siku-integration/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix, it is very important that you fill out this part, issues without it will be closed" 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /fake_fan/send_packet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Send a single UDP command to the fake fan server - useful for quick testing.""" 3 | # ruff: noqa: T201 4 | 5 | import argparse 6 | import socket 7 | 8 | 9 | def send_command(host: str, port: int, packet_hex: str): 10 | """Send a hex packet to the fan server.""" 11 | print(f"Sending to {host}:{port}") 12 | print(f"Packet: {packet_hex}") 13 | print(f"Length: {len(packet_hex) // 2} bytes\n") 14 | 15 | packet_data = bytes.fromhex(packet_hex) 16 | 17 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: 18 | sock.settimeout(2) 19 | sock.sendto(packet_data, (host, port)) 20 | 21 | try: 22 | result_data, server = sock.recvfrom(4096) 23 | result_hex = result_data.hex().upper() 24 | print(f"Response from {server[0]}:{server[1]}") 25 | print(f"Packet: {result_hex}") 26 | print(f"Length: {len(result_data)} bytes") 27 | return result_hex 28 | except TimeoutError: 29 | print("No response (timeout)") 30 | return None 31 | 32 | 33 | def main(): 34 | """Run manual packet sender.""" 35 | parser = argparse.ArgumentParser( 36 | description="Send raw UDP packets to the fake fan server", 37 | formatter_class=argparse.RawDescriptionHelpFormatter, 38 | epilog=""" 39 | Examples: 40 | # Read device type (auth: ID=1234567890123456, password=1234) 41 | %(prog)s FDFD02103132333435363738393031323334353637383930043132333401B906ED 42 | 43 | # Turn fan ON 44 | %(prog)s FDFD021031323334353637383930313233343536373839300431323334030101013F 45 | 46 | # Set speed to 5 47 | %(prog)s FDFD0210313233343536373839303132333435363738393004313233340302050248 48 | 49 | Note: Packets must include valid authentication and checksum. 50 | """, 51 | ) 52 | parser.add_argument("packet", help="Hex packet to send (no spaces)") 53 | parser.add_argument( 54 | "--host", default="127.0.0.1", help="Target host (default: 127.0.0.1)" 55 | ) 56 | parser.add_argument( 57 | "--port", type=int, default=4000, help="Target port (default: 4000)" 58 | ) 59 | 60 | args = parser.parse_args() 61 | 62 | # Remove spaces and make uppercase 63 | packet = args.packet.replace(" ", "").upper() 64 | 65 | send_command(args.host, args.port, packet) 66 | 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /fake_fan/test_fake_fan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Quick test script to verify the fake fan server works.""" 3 | # ruff: noqa: T201 4 | 5 | import asyncio 6 | import sys 7 | from pathlib import Path 8 | 9 | # Add the custom_components directory to the path 10 | sys.path.insert(0, str(Path(__file__).parent.parent / "custom_components")) 11 | 12 | from siku.api_v2 import SikuV2Api 13 | 14 | 15 | async def test_fan_server(): 16 | """Test the fake fan server.""" 17 | print("Testing Fake Fan Server") 18 | print("=" * 60) 19 | 20 | # Create API client 21 | api = SikuV2Api( 22 | host="127.0.0.1", port=4000, idnum="1234567890123456", password="1234" 23 | ) 24 | 25 | try: 26 | # Test 1: Get status 27 | print("\n1. Getting initial status...") 28 | status = await api.status() 29 | print(f" ✓ Power: {'ON' if status['is_on'] else 'OFF'}") 30 | print(f" ✓ Speed: {status['speed']}") 31 | print(f" ✓ Direction: {status['direction']}") 32 | print(f" ✓ Humidity: {status['humidity']}%") 33 | print(f" ✓ RPM: {status['rpm']}") 34 | 35 | # Test 2: Turn on 36 | print("\n2. Turning fan ON...") 37 | status = await api.power_on() 38 | print(f" ✓ Power: {'ON' if status['is_on'] else 'OFF'}") 39 | 40 | # Test 3: Set speed 41 | print("\n3. Setting speed to 2...") 42 | status = await api.speed("02") 43 | print(f" ✓ Speed: {status['speed']}") 44 | 45 | # Test 4: Set manual speed to 50% 46 | print("\n4. Setting manual speed to 50%...") 47 | status = await api.speed_manual(50) 48 | print( 49 | f" ✓ Manual speed: {status['manual_speed']}/255 ({status['manual_speed'] / 255 * 100:.1f}%)" 50 | ) 51 | 52 | # Test 5: Set direction to reverse 53 | print("\n5. Setting direction to reverse...") 54 | status = await api.direction("01") 55 | print(f" ✓ Direction: {status['direction']}") 56 | 57 | # Test 6: Enable sleep mode 58 | print("\n6. Enabling sleep mode...") 59 | status = await api.sleep() 60 | print(f" ✓ Mode: {status['mode']}") 61 | 62 | # Test 7: Turn off 63 | print("\n7. Turning fan OFF...") 64 | status = await api.power_off() 65 | print(f" ✓ Power: {'ON' if status['is_on'] else 'OFF'}") 66 | 67 | print("\n" + "=" * 60) 68 | print("✓ All tests passed!") 69 | print("\nThe fake fan server is working correctly.") 70 | 71 | except Exception as e: 72 | print(f"\n✗ Error: {e}") 73 | print("\nMake sure the fake fan server is running:") 74 | print(" python scripts/fake_fan_server.py") 75 | return False 76 | 77 | return True 78 | 79 | 80 | if __name__ == "__main__": 81 | success = asyncio.run(test_fan_server()) 82 | sys.exit(0 if success else 1) 83 | -------------------------------------------------------------------------------- /custom_components/siku/__init__.py: -------------------------------------------------------------------------------- 1 | """The Siku Fan integration.""" 2 | 3 | from __future__ import annotations 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import Platform 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | 12 | from .const import DOMAIN, DEFAULT_NAME 13 | from .coordinator import SikuDataUpdateCoordinator 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR, Platform.BUTTON] 18 | 19 | 20 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 21 | """Set up Siku Fan from a config entry.""" 22 | 23 | coordinator = SikuDataUpdateCoordinator(hass, entry) 24 | await coordinator.async_config_entry_first_refresh() 25 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator 26 | 27 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 28 | 29 | return True 30 | 31 | 32 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 33 | """Unload a config entry.""" 34 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 35 | hass.data[DOMAIN].pop(entry.entry_id) 36 | 37 | return unload_ok 38 | 39 | 40 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 41 | """Migrate old entry.""" 42 | _LOGGER.debug( 43 | "Migrating configuration from version %s.%s", 44 | config_entry.version, 45 | config_entry.minor_version, 46 | ) 47 | 48 | if config_entry.version > 1: 49 | # This means the user has downgraded from a future version 50 | return False 51 | 52 | if config_entry.version == 1: 53 | new_data = {**config_entry.data} 54 | 55 | if config_entry.minor_version < 1: 56 | host = config_entry.data[CONF_IP_ADDRESS] 57 | port = config_entry.data[CONF_PORT] 58 | unique_id = f"{host}:{port}" 59 | fix_names = [ 60 | f"{DOMAIN} {host}:{port}", 61 | f"{DEFAULT_NAME} {host}:{port}", 62 | f"{DOMAIN} {host}", 63 | f"{host}:{port}", 64 | ] 65 | if config_entry.title in fix_names: 66 | title = f"{DEFAULT_NAME} {host}" 67 | else: 68 | title = config_entry.title 69 | hass.config_entries.async_update_entry( 70 | config_entry, 71 | data=new_data, 72 | unique_id=unique_id, 73 | title=title, 74 | version=1, 75 | minor_version=1, 76 | ) 77 | 78 | _LOGGER.debug( 79 | "Migration to configuration version %s.%s successful", 80 | config_entry.version, 81 | config_entry.minor_version, 82 | ) 83 | 84 | return True 85 | 86 | 87 | class SikuEntity(CoordinatorEntity[SikuDataUpdateCoordinator]): 88 | """Representation of a siku entity.""" 89 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "38 23 * * 0" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v6 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v4 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v4 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 64 | 65 | # If the Autobuild fails above, remove it and uncomment the following three lines. 66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 67 | 68 | # - run: | 69 | # echo "Run, Build Application using script" 70 | # ./location_of_script_within_repo/buildscript.sh 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v4 74 | with: 75 | category: "/language:${{matrix.language}}" -------------------------------------------------------------------------------- /custom_components/siku/button.py: -------------------------------------------------------------------------------- 1 | """Siku fan buttons.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | from homeassistant.components.button import ( 9 | ButtonDeviceClass, 10 | ButtonEntity, 11 | ButtonEntityDescription, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import ( 15 | EntityCategory, 16 | ) 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | 20 | from . import SikuEntity 21 | from .const import DOMAIN 22 | from .coordinator import SikuDataUpdateCoordinator 23 | 24 | LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | @dataclass(frozen=True, kw_only=True) 28 | class SikuButtonEntityDescription(ButtonEntityDescription): 29 | """Describes Siku fan button entity.""" 30 | 31 | action: str 32 | 33 | 34 | BUTTONS = [ 35 | SikuButtonEntityDescription( 36 | key="reset_filter_alarm", 37 | name="Reset filter alarm", 38 | icon="mdi:alarm-light", 39 | device_class=ButtonDeviceClass.UPDATE, 40 | entity_category=EntityCategory.CONFIG, 41 | action="reset_filter_alarm", 42 | ), 43 | SikuButtonEntityDescription( 44 | key="party", 45 | name="Party mode", 46 | device_class=ButtonDeviceClass.UPDATE, 47 | entity_category=EntityCategory.CONFIG, 48 | action="party", 49 | ), 50 | SikuButtonEntityDescription( 51 | key="sleep", 52 | name="Sleep mode", 53 | device_class=ButtonDeviceClass.UPDATE, 54 | entity_category=EntityCategory.CONFIG, 55 | action="sleep", 56 | ), 57 | ] 58 | 59 | 60 | async def async_setup_entry( 61 | hass: HomeAssistant, 62 | entry: ConfigEntry, 63 | async_add_entities: AddEntitiesCallback, 64 | ) -> None: 65 | """Set up the Siku fan buttons.""" 66 | LOGGER.debug("Setting up Siku fan buttons %s", entry.entry_id) 67 | coordinator = hass.data[DOMAIN][entry.entry_id] 68 | 69 | entities: list[SikuButton] = [ 70 | SikuButton(hass, coordinator, description) for description in BUTTONS 71 | ] 72 | 73 | async_add_entities(entities, True) 74 | 75 | 76 | class SikuButton(SikuEntity, ButtonEntity): 77 | """Representation of a Siku related Button.""" 78 | 79 | entity_description: SikuButtonEntityDescription 80 | 81 | def __init__( 82 | self, 83 | hass: HomeAssistant, 84 | coordinator: SikuDataUpdateCoordinator, 85 | description: ButtonEntityDescription, 86 | ) -> None: 87 | """Initialize the entity.""" 88 | super().__init__(coordinator=coordinator, context=description.key) 89 | self.hass = hass 90 | self.entity_description = description 91 | self._attr_device_info = coordinator.device_info 92 | self._attr_unique_id = f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-{description.key}-button" 93 | LOGGER.debug("Add Siku button entity %s", self._attr_unique_id) 94 | 95 | async def async_press(self) -> None: 96 | """Send out a persistent notification.""" 97 | try: 98 | method = getattr(self.coordinator.api, self.entity_description.action) 99 | response = await method() 100 | self.coordinator.async_set_updated_data(response) 101 | self.async_write_ha_state() 102 | except AttributeError: 103 | LOGGER.warning("No such method: %s", self.entity_description.action) 104 | -------------------------------------------------------------------------------- /custom_components/siku/coordinator.py: -------------------------------------------------------------------------------- 1 | """Data update coordinator for the Deluge integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import time 7 | from datetime import timedelta 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import CONF_IP_ADDRESS 11 | from homeassistant.const import CONF_PASSWORD 12 | from homeassistant.const import CONF_PORT 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.device_registry import DeviceInfo 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 16 | from homeassistant.helpers.update_coordinator import UpdateFailed 17 | 18 | from .api_v1 import SikuV1Api 19 | from .api_v2 import SikuV2Api 20 | from .const import ( 21 | CONF_ID, 22 | DEFAULT_MODEL, 23 | DEFAULT_NAME, 24 | ) 25 | from .const import CONF_VERSION 26 | from .const import DOMAIN 27 | from .const import DEFAULT_MANUFACTURER 28 | 29 | LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | class SikuDataUpdateCoordinator(DataUpdateCoordinator): 33 | """Data update coordinator for the Deluge integration.""" 34 | 35 | config_entry: ConfigEntry 36 | 37 | def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 38 | """Initialize the coordinator.""" 39 | self.config_entry = entry 40 | 41 | if entry.data[CONF_VERSION] == 1: 42 | self.api = SikuV1Api(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]) 43 | else: 44 | self.api = SikuV2Api( 45 | entry.data[CONF_IP_ADDRESS], 46 | entry.data[CONF_PORT], 47 | entry.data[CONF_ID], 48 | entry.data[CONF_PASSWORD], 49 | ) 50 | name = f"{DEFAULT_NAME} {entry.data[CONF_IP_ADDRESS]}" 51 | 52 | super().__init__( 53 | hass=hass, 54 | logger=LOGGER, 55 | name=name, 56 | update_interval=timedelta(seconds=30), 57 | update_method=self._update_method, 58 | ) 59 | 60 | @property 61 | def device_info(self) -> DeviceInfo: 62 | """Return the DeviceInfo of this Siku fan using IP as identifier.""" 63 | return DeviceInfo( 64 | identifiers={(DOMAIN, f"{self.api.host}:{self.api.port}")}, 65 | model=DEFAULT_MODEL, 66 | manufacturer=DEFAULT_MANUFACTURER, 67 | name=self.name or f"{DEFAULT_NAME} {self.api.host}", 68 | ) 69 | 70 | async def _update_method(self) -> dict[str, int | str]: 71 | """Get the latest data from Siku fan and updates the state.""" 72 | start_time = time.time() 73 | try: 74 | data: dict = await self.api.status() 75 | elapsed = time.time() - start_time 76 | self.logger.debug( 77 | "Fetched status from %s:%d in %.3f seconds", 78 | self.api.host, 79 | self.api.port, 80 | elapsed, 81 | ) 82 | return data 83 | except TimeoutError as ex: 84 | elapsed = time.time() - start_time 85 | error_msg = ( 86 | f"Timeout connecting to Siku Fan at {self.api.host}:{self.api.port} " 87 | f"after {elapsed:.3f}s: {ex}" 88 | ) 89 | self.logger.error(error_msg) 90 | raise UpdateFailed(error_msg) from ex 91 | except (OSError, LookupError) as ex: 92 | elapsed = time.time() - start_time 93 | error_msg = ( 94 | f"Connection to Siku Fan at {self.api.host}:{self.api.port} failed " 95 | f"after {elapsed:.3f}s: {ex}" 96 | ) 97 | self.logger.error(error_msg) 98 | raise UpdateFailed(error_msg) from ex 99 | -------------------------------------------------------------------------------- /tests/test_coordinator.py: -------------------------------------------------------------------------------- 1 | """Tests for the Siku Data Update Coordinator.""" 2 | 3 | import pytest 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | from custom_components.siku.coordinator import SikuDataUpdateCoordinator 6 | from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT 7 | from homeassistant.helpers.update_coordinator import UpdateFailed 8 | 9 | from custom_components.siku.const import ( 10 | CONF_ID, 11 | CONF_VERSION, 12 | DEFAULT_MODEL, 13 | DEFAULT_NAME, 14 | DOMAIN, 15 | DEFAULT_MANUFACTURER, 16 | ) 17 | 18 | # ruff: noqa: D103 19 | 20 | 21 | @pytest.fixture 22 | def mock_hass(): 23 | return MagicMock() 24 | 25 | 26 | @pytest.fixture 27 | def config_entry_v1(): 28 | entry = MagicMock() 29 | entry.data = { 30 | CONF_IP_ADDRESS: "192.168.1.10", 31 | CONF_PORT: 1234, 32 | CONF_VERSION: 1, 33 | } 34 | return entry 35 | 36 | 37 | @pytest.fixture 38 | def config_entry_v2(): 39 | entry = MagicMock() 40 | entry.data = { 41 | CONF_IP_ADDRESS: "192.168.1.20", 42 | CONF_PORT: 5678, 43 | CONF_VERSION: 2, 44 | CONF_ID: "1234567890ABCDEF", 45 | CONF_PASSWORD: "12345678", 46 | } 47 | return entry 48 | 49 | 50 | @patch("custom_components.siku.coordinator.SikuV1Api") 51 | def test_coordinator_init_v1(mock_v1api, mock_hass, config_entry_v1): 52 | coordinator = SikuDataUpdateCoordinator(mock_hass, config_entry_v1) 53 | mock_v1api.assert_called_once_with("192.168.1.10", 1234) 54 | assert coordinator.name == f"{DEFAULT_NAME} 192.168.1.10" 55 | assert coordinator.api == mock_v1api.return_value 56 | 57 | 58 | @patch("custom_components.siku.coordinator.SikuV2Api") 59 | def test_coordinator_init_v2(mock_v2api, mock_hass, config_entry_v2): 60 | coordinator = SikuDataUpdateCoordinator(mock_hass, config_entry_v2) 61 | mock_v2api.assert_called_once_with( 62 | "192.168.1.20", 5678, "1234567890ABCDEF", "12345678" 63 | ) 64 | assert coordinator.name == f"{DEFAULT_NAME} 192.168.1.20" 65 | assert coordinator.api == mock_v2api.return_value 66 | 67 | 68 | @patch("custom_components.siku.coordinator.SikuV1Api") 69 | def test_device_info_v1(mock_v1api, mock_hass, config_entry_v1): 70 | mock_api = MagicMock() 71 | mock_api.host = "192.168.1.10" 72 | mock_api.port = 1234 73 | mock_v1api.return_value = mock_api 74 | coordinator = SikuDataUpdateCoordinator(mock_hass, config_entry_v1) 75 | info = coordinator.device_info 76 | assert info.get("identifiers") == {(DOMAIN, "192.168.1.10:1234")} 77 | assert info.get("model") == DEFAULT_MODEL 78 | assert info.get("manufacturer") == DEFAULT_MANUFACTURER 79 | assert info.get("name") == f"{DEFAULT_NAME} 192.168.1.10" 80 | 81 | 82 | @pytest.mark.asyncio 83 | @patch("custom_components.siku.coordinator.SikuV1Api") 84 | async def test_update_method_success(mock_v1api, mock_hass, config_entry_v1): 85 | mock_api = MagicMock() 86 | mock_api.status = AsyncMock(return_value={"is_on": True, "speed": "01"}) 87 | mock_v1api.return_value = mock_api 88 | coordinator = SikuDataUpdateCoordinator(mock_hass, config_entry_v1) 89 | data = await coordinator._update_method() 90 | assert data == {"is_on": True, "speed": "01"} 91 | 92 | 93 | @pytest.mark.asyncio 94 | @patch("custom_components.siku.coordinator.SikuV1Api") 95 | async def test_update_method_failure(mock_v1api, mock_hass, config_entry_v1): 96 | mock_api = MagicMock() 97 | mock_api.host = "192.168.1.100" 98 | mock_api.port = 4000 99 | mock_api.status = AsyncMock(side_effect=TimeoutError("timeout")) 100 | mock_v1api.return_value = mock_api 101 | coordinator = SikuDataUpdateCoordinator(mock_hass, config_entry_v1) 102 | with pytest.raises(UpdateFailed) as exc: 103 | await coordinator._update_method() 104 | assert "Timeout connecting to Siku Fan" in str(exc.value) 105 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | You should also verify that existing [tests](./tests) are still working 67 | and you are encouraged to add new ones. 68 | You can run the tests using the following commands from the root folder: 69 | 70 | ```bash 71 | # Create a virtual environment 72 | python3 -m venv venv 73 | source venv/bin/activate 74 | # Install requirements 75 | pip install -r requirements.txt 76 | # Run tests and get a summary of successes/failures and code coverage 77 | pytest --durations=10 --cov-report term-missing --cov=custom_components.siku tests 78 | ``` 79 | 80 | If any of the tests fail, make the necessary changes to the tests as part of 81 | your changes to the integration. 82 | 83 | ## Pre-commit 84 | 85 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 86 | repostory to have code style and linting checks. 87 | 88 | With `pre-commit` tool already installed, 89 | activate the settings of the repository: 90 | 91 | ```console 92 | $ pre-commit install 93 | ``` 94 | 95 | Now the pre-commit tests will be done every time you commit. 96 | 97 | You can run the tests on all repository file with the command: 98 | 99 | ```console 100 | $ pre-commit run --all-files 101 | ``` 102 | 103 | ## License 104 | 105 | By contributing, you agree that your contributions will be licensed under its MIT License. 106 | -------------------------------------------------------------------------------- /fake_fan/README.md: -------------------------------------------------------------------------------- 1 | # Fake Siku Fan Server 2 | 3 | This script simulates a Siku fan controller for testing purposes. It implements the UDP protocol used by real Siku fans, allowing you to test the Home Assistant integration without physical hardware. 4 | 5 | ## Quick Start 6 | 7 | **Start the server:** 8 | ```bash 9 | python fake_fan/fake_fan_server.py 10 | ``` 11 | 12 | **Test it works:** 13 | ```bash 14 | python fake_fan/test_fake_fan.py 15 | ``` 16 | 17 | That's it! The fake fan is now running and responding to commands. 18 | 19 | ## Features 20 | 21 | - ✅ Full protocol implementation (read, write, read-write commands) 22 | - ✅ Simulates fan state (on/off, speed, direction, etc.) 23 | - ✅ Verbose logging of all traffic 24 | - ✅ Configurable device ID and password 25 | - ✅ Supports all fan commands (speed, direction, boost, modes, etc.) 26 | 27 | ## Usage 28 | 29 | ### Basic Usage 30 | 31 | Run with default settings (listens on `0.0.0.0:4000`, ID: `1234567890123456`, Password: `1234`): 32 | 33 | ```bash 34 | python fake_fan/fake_fan_server.py 35 | ``` 36 | 37 | ### Custom Configuration 38 | 39 | ```bash 40 | python fake_fan/fake_fan_server.py \ 41 | --host 0.0.0.0 \ 42 | --port 4000 \ 43 | --id "mydevice123456789012" \ 44 | --password "secret" 45 | ``` 46 | 47 | ### With Debug Logging 48 | 49 | ```bash 50 | python fake_fan/fake_fan_server.py --debug 51 | ``` 52 | 53 | ## Configuration Options 54 | 55 | | Option | Default | Description | 56 | |--------|---------|-------------| 57 | | `--host` | `0.0.0.0` | Host/IP address to bind to | 58 | | `--port` | `4000` | UDP port to listen on | 59 | | `--id` | `1234567890123456` | Device ID (20 characters) | 60 | | `--password` | `1234` | Device password | 61 | | `--debug` | `False` | Enable debug logging | 62 | 63 | ## Using with Home Assistant 64 | 65 | 1. Start the fake fan server: 66 | ```bash 67 | python fake_fan/fake_fan_server.py 68 | ``` 69 | 70 | 2. In Home Assistant, add the Siku integration with these settings: 71 | - Host: `localhost` (or your server IP) 72 | - Port: `4000` 73 | - Device ID: `1234567890123456` 74 | - Password: `1234` 75 | 76 | 3. The fake fan will appear in Home Assistant and respond to all commands! 77 | 78 | ## Example Output 79 | 80 | When you interact with the fan through Home Assistant, you'll see detailed logs: 81 | 82 | ``` 83 | ============================================================ 84 | RECEIVED: FDFD02103132333435363738393031323334353637383930043132333401B9 85 | Length: 31 bytes 86 | ✓ Checksum verified 87 | ✓ Authentication successful 88 | Function: 01 89 | Command: READ 90 | RESPONSE: FDFD02103132333435363738393031323334353637383930043132333406B901 91 | Length: 32 bytes 92 | ============================================================ 93 | ``` 94 | 95 | ## Simulated Fan State 96 | 97 | The fake fan maintains the following state: 98 | 99 | - **Power**: On/Off 100 | - **Speed**: 1-10 (preset speeds) 101 | - **Manual Speed**: 0-255 (fine control) 102 | - **Direction**: Forward, Reverse, Alternating 103 | - **Boost Mode**: Enabled/Disabled 104 | - **Mode**: Auto, Sleep, Party 105 | - **Humidity**: Current humidity (simulated at 45%) 106 | - **RPM**: Fan speed in RPM (simulated at 1200) 107 | - **Filter Timer**: Time since filter change 108 | - **Countdown Timer**: Active countdown timer 109 | - **Alarm**: Filter alarm status 110 | - **Firmware**: Version 2.5 111 | 112 | ## Commands Supported 113 | 114 | All Siku protocol commands are supported: 115 | 116 | - Power on/off/toggle 117 | - Speed control (preset 1-10) 118 | - Manual speed control (0-255) 119 | - Direction control 120 | - Boost mode 121 | - Sleep/Party modes 122 | - Timer control 123 | - Filter reset 124 | - Status queries 125 | - And more... 126 | 127 | ## Development 128 | 129 | The server logs all incoming packets and responses, making it easy to: 130 | 131 | 1. Debug protocol issues 132 | 2. Verify packet format 133 | 3. Test edge cases 134 | 4. Develop new features 135 | 136 | ## Tools Included 137 | 138 | ### `fake_fan_server.py` 139 | The main fake fan server that simulates a real Siku fan controller. 140 | 141 | ### `test_fake_fan.py` 142 | Automated test script that exercises all fan commands. Great for verifying the server works. 143 | 144 | ```bash 145 | python fake_fan/test_fake_fan.py 146 | ``` 147 | 148 | ### `send_packet.py` 149 | Send raw UDP packets for low-level testing: 150 | 151 | ```bash 152 | # Read device type 153 | python fake_fan/send_packet.py FDFD02103132333435363738393031323334353637383930043132333401B906ED 154 | 155 | # Turn fan ON 156 | python fake_fan/send_packet.py FDFD021031323334353637383930313233343536373839300431323334030101013F 157 | ``` 158 | 159 | ## Documentation 160 | 161 | - **`README.md`** (this file) - Overview and usage 162 | - **`TESTING_GUIDE.md`** - Comprehensive testing guide with examples 163 | 164 | ## Notes 165 | 166 | - The server uses UDP (like real Siku fans) 167 | - Authentication is enforced (device ID + password must match) 168 | - Checksums are verified on all packets 169 | - All protocol functions are implemented (READ, WRITE, READ_WRITE, INC, DEC) 170 | -------------------------------------------------------------------------------- /custom_components/siku/udp.py: -------------------------------------------------------------------------------- 1 | """Async UDP helper for Siku integration. 2 | 3 | Provides a small wrapper around asyncio DatagramProtocol/Transport 4 | to send a payload and await a single response with timeout, plus 5 | support for fire-and-forget send. 6 | """ 7 | 8 | from __future__ import annotations 9 | import time 10 | import asyncio 11 | import logging 12 | 13 | LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class _UdpProtocol(asyncio.DatagramProtocol): 17 | def __init__(self) -> None: 18 | self.transport: asyncio.DatagramTransport | None = None 19 | self._future: asyncio.Future[tuple[bytes, tuple[str, int]]] | None = None 20 | 21 | def connection_made(self, transport: asyncio.BaseTransport) -> None: 22 | # transport will be a DatagramTransport 23 | self.transport = transport # type: ignore[assignment] 24 | 25 | def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: 26 | if self._future and not self._future.done(): 27 | self._future.set_result((data, addr)) 28 | 29 | def error_received(self, exc: Exception) -> None: 30 | if self._future and not self._future.done(): 31 | self._future.set_exception(exc) 32 | 33 | def connection_lost(self, exc: Exception | None) -> None: 34 | if self._future and not self._future.done(): 35 | self._future.set_exception(exc or ConnectionError("UDP connection lost")) 36 | 37 | async def request( 38 | self, payload: bytes, timeout: float, addr: tuple[str, int] 39 | ) -> tuple[bytes, tuple[str, int]]: 40 | loop = asyncio.get_running_loop() 41 | self._future = loop.create_future() 42 | assert self.transport is not None 43 | self.transport.sendto(payload, addr) 44 | return await asyncio.wait_for(self._future, timeout=timeout) 45 | 46 | def send_only(self, payload: bytes, addr: tuple[str, int]) -> None: 47 | assert self.transport is not None 48 | self.transport.sendto(payload, addr) 49 | 50 | 51 | class AsyncUdpClient: 52 | """Reusable UDP client using asyncio datagram transport.""" 53 | 54 | def __init__(self, host: str, port: int) -> None: 55 | """Initialize the UDP client for a host and port.""" 56 | self._host = host 57 | self._port = port 58 | self._transport: asyncio.DatagramTransport | None = None 59 | self._protocol: _UdpProtocol | None = None 60 | self._lock = asyncio.Lock() 61 | 62 | async def ensure_transport(self) -> None: 63 | """Ensure the asyncio datagram transport is created.""" 64 | if self._transport and self._protocol: 65 | return 66 | loop = asyncio.get_running_loop() 67 | transport, protocol = await loop.create_datagram_endpoint( 68 | _UdpProtocol, 69 | remote_addr=(self._host, self._port), 70 | ) 71 | self._transport = transport # type: ignore[assignment] 72 | self._protocol = protocol # type: ignore[assignment] 73 | 74 | async def close(self) -> None: 75 | """Close the transport and release resources.""" 76 | if self._transport: 77 | self._transport.close() 78 | self._transport = None 79 | self._protocol = None 80 | 81 | async def request(self, payload: bytes, timeout: float = 5.0) -> bytes: 82 | """Send a payload and await the response with a timeout.""" 83 | await self.ensure_transport() 84 | assert self._protocol is not None 85 | async with self._lock: 86 | start_time = time.time() 87 | try: 88 | LOGGER.debug( 89 | "[UDP %s:%d] Sending %d bytes, waiting for response (timeout=%.1fs)", 90 | self._host, 91 | self._port, 92 | len(payload), 93 | timeout, 94 | ) 95 | data, _ = await self._protocol.request( 96 | payload, timeout, (self._host, self._port) 97 | ) 98 | elapsed = time.time() - start_time 99 | LOGGER.debug( 100 | "[UDP %s:%d] Received %d bytes in %.3f seconds", 101 | self._host, 102 | self._port, 103 | len(data), 104 | elapsed, 105 | ) 106 | return data 107 | except asyncio.TimeoutError: 108 | elapsed = time.time() - start_time 109 | LOGGER.warning( 110 | "[UDP %s:%d] No response received after %.3f seconds (timeout=%.1fs)", 111 | self._host, 112 | self._port, 113 | elapsed, 114 | timeout, 115 | ) 116 | raise 117 | 118 | async def send_only(self, payload: bytes) -> None: 119 | """Send a payload without waiting for a response.""" 120 | await self.ensure_transport() 121 | assert self._protocol is not None 122 | async with self._lock: 123 | self._protocol.send_only(payload, (self._host, self._port)) 124 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the Siku Integration config flow.""" 2 | 3 | import pytest 4 | from unittest.mock import patch, AsyncMock 5 | from homeassistant.data_entry_flow import FlowResultType 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT 8 | from custom_components.siku.const import ( 9 | DOMAIN, 10 | DEFAULT_PORT, 11 | DEFAULT_NAME, 12 | CONF_ID, 13 | CONF_VERSION, 14 | ) 15 | 16 | # ruff: noqa: D103 17 | 18 | IP_ADDRESS = "192.168.1.100" 19 | PORT = DEFAULT_PORT 20 | IDNUM = "1234567890abcdef" 21 | PASSWORD = "pass1234" 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_show_user_form(hass: HomeAssistant): 26 | """Test that the user step shows the form.""" 27 | result = await hass.config_entries.flow.async_init( 28 | DOMAIN, context={"source": "user"} 29 | ) 30 | assert result.get("type") == FlowResultType.FORM 31 | assert result.get("step_id") == "user" 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_create_entry_v1(hass: HomeAssistant): 36 | """Test creating an entry for API v1.""" 37 | user_input = { 38 | CONF_IP_ADDRESS: IP_ADDRESS, 39 | CONF_PORT: PORT, 40 | CONF_VERSION: 1, 41 | } 42 | with patch("custom_components.siku.config_flow.SikuV1Api") as mock_api: 43 | mock_api.return_value.status = AsyncMock(return_value=True) 44 | result = await hass.config_entries.flow.async_init( 45 | DOMAIN, context={"source": "user"}, data=user_input 46 | ) 47 | assert result.get("type") == FlowResultType.CREATE_ENTRY 48 | assert result.get("title") == f"{DEFAULT_NAME} {IP_ADDRESS}" 49 | assert result.get("data") == user_input 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_create_entry_v2(hass: HomeAssistant): 54 | """Test creating an entry for API v2.""" 55 | user_input = { 56 | CONF_IP_ADDRESS: IP_ADDRESS, 57 | CONF_PORT: PORT, 58 | CONF_VERSION: 2, 59 | CONF_ID: IDNUM, 60 | CONF_PASSWORD: PASSWORD, 61 | } 62 | with patch("custom_components.siku.config_flow.SikuV2Api") as mock_api: 63 | mock_api.return_value.status = AsyncMock(return_value=True) 64 | result = await hass.config_entries.flow.async_init( 65 | DOMAIN, context={"source": "user"}, data=user_input 66 | ) 67 | assert result.get("type") == FlowResultType.CREATE_ENTRY 68 | assert result.get("title") == f"{DEFAULT_NAME} {IP_ADDRESS}" 69 | assert result.get("data") == user_input 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_invalid_idnum_length(hass: HomeAssistant): 74 | """Test error when idnum is not 16 chars.""" 75 | user_input = { 76 | CONF_IP_ADDRESS: IP_ADDRESS, 77 | CONF_PORT: PORT, 78 | CONF_VERSION: 2, 79 | CONF_ID: "shortid", 80 | CONF_PASSWORD: PASSWORD, 81 | } 82 | result = await hass.config_entries.flow.async_init( 83 | DOMAIN, context={"source": "user"}, data=user_input 84 | ) 85 | assert result.get("type") == FlowResultType.FORM 86 | errors = result.get("errors") or {} 87 | assert errors.get("base") == "invalid_idnum" 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_invalid_password_length(hass: HomeAssistant): 92 | """Test error when password is too long.""" 93 | user_input = { 94 | CONF_IP_ADDRESS: IP_ADDRESS, 95 | CONF_PORT: PORT, 96 | CONF_VERSION: 2, 97 | CONF_ID: IDNUM, 98 | CONF_PASSWORD: "toolongpassword", 99 | } 100 | result = await hass.config_entries.flow.async_init( 101 | DOMAIN, context={"source": "user"}, data=user_input 102 | ) 103 | assert result.get("type") == FlowResultType.FORM 104 | errors = result.get("errors") or {} 105 | assert errors.get("base") == "invalid_password" 106 | assert errors.get("password") == "invalid_password" 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_cannot_connect(hass: HomeAssistant): 111 | """Test cannot connect error.""" 112 | user_input = { 113 | CONF_IP_ADDRESS: IP_ADDRESS, 114 | CONF_PORT: PORT, 115 | CONF_VERSION: 1, 116 | } 117 | with patch("custom_components.siku.config_flow.SikuV1Api") as mock_api: 118 | mock_api.return_value.status = AsyncMock(return_value=False) 119 | result = await hass.config_entries.flow.async_init( 120 | DOMAIN, context={"source": "user"}, data=user_input 121 | ) 122 | assert result.get("type") == FlowResultType.FORM 123 | errors = result.get("errors") or {} 124 | assert errors.get("base") == "cannot_connect" 125 | 126 | 127 | @pytest.mark.asyncio 128 | async def test_unknown_exception(hass: HomeAssistant): 129 | """Test unknown exception handling.""" 130 | user_input = { 131 | CONF_IP_ADDRESS: IP_ADDRESS, 132 | CONF_PORT: PORT, 133 | CONF_VERSION: 1, 134 | } 135 | with patch("custom_components.siku.config_flow.SikuV1Api") as mock_api: 136 | mock_api.return_value.status = AsyncMock(side_effect=Exception("fail")) 137 | result = await hass.config_entries.flow.async_init( 138 | DOMAIN, context={"source": "user"}, data=user_input 139 | ) 140 | assert result.get("type") == FlowResultType.FORM 141 | errors = result.get("errors") or {} 142 | assert errors.get("base") == "unknown" 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Siku (Blauberg) Fan integration 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]](LICENSE) 6 | 7 | [![pre-commit][pre-commit-shield]][pre-commit] 8 | [![Black][black-shield]][black] 9 | 10 | [![hacs][hacsbadge]][hacs] 11 | [![Project Maintenance][maintenance-shield]][user_profile] 12 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 13 | 14 | [![Discord][discord-shield]][discord] 15 | [![Community Forum][forum-shield]][forum] 16 | 17 | **This component will set up the following platforms.** 18 | 19 | | Platform | Description | 20 | | -------- | ----------- | 21 | | `fan` | Siku Fan | 22 | 23 | Integration for https://www.siku.at/produkte/ wifi fans 24 | 25 | ### Tested on 26 | 27 | - "Siku RV 50 W Pro WIFI v1" 28 | 29 | The fan is sold under different brands, for instance : 30 | 31 | - [SIKU RV](https://www.siku.at/produkte/) 32 | - [Blauberg Group](https://blauberg-group.com) 33 | - [Blauberg Ventilatoren](https://blaubergventilatoren.de/en/catalog/single-room-reversible-units-vento/functions/2899) 34 | - [VENTS Twinfresh](https://ventilation-system.com/catalog/decentralized-hru-for-residential-use/) 35 | - Breezy fans use a new protocol and is incompatible with this integration 36 | - [DUKA One](https://dukaventilation.dk/produkter/1-rums-ventilationsloesninger) 37 | - [Oxxify](https://raumluft-shop.de/lueftung/dezentrale-lueftungsanlage-mit-waermerueckgewinnung/oxxify.html) 38 | - [Twinfresh](https://foris.no/produktkategori/miniventilasjon/miniventilasjon-miniventilasjon/) 39 | 40 | ## Installation 41 | 42 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hmn&repository=siku-integration&category=integration) 43 | 44 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 45 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 46 | 3. In the `custom_components` directory (folder) create a new folder called `siku`. 47 | 4. Download _all_ the files from the `custom_components/siku/` directory (folder) in this repository. 48 | 5. Place the files you downloaded in the new directory (folder) you created. 49 | 6. Restart Home Assistant 50 | 7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Siku Fan integration" 51 | 52 | Using your HA configuration directory (folder) as a starting point you should now also have this: 53 | 54 | ```text 55 | custom_components/siku/translations/en.json 56 | custom_components/siku/__init__.py 57 | custom_components/siku/api.py 58 | custom_components/siku/config_flow.py 59 | custom_components/siku/const.py 60 | custom_components/siku/cordinator.py 61 | custom_components/siku/fan.py 62 | custom_components/siku/manifest.json 63 | custom_components/siku/strings.json 64 | ``` 65 | 66 | ## Configuration is done in the UI 67 | 68 | 69 | 70 | ## Report issues 71 | 72 | If you have any issues with this integration, please [open an issue](https://github.com/hmn/siku-integration/issues). 73 | 74 | Make sure to include debug logs. See https://www.home-assistant.io/integrations/logger/ for more information on how to enable debug logs. 75 | 76 | ``` 77 | logger: 78 | default: info 79 | logs: 80 | custom_components.siku: debug 81 | ``` 82 | 83 | ## Contributions are welcome! 84 | 85 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 86 | 87 | ## Credits 88 | 89 | This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. 90 | 91 | Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template 92 | 93 | --- 94 | 95 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 96 | [black]: https://github.com/psf/black 97 | [black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 98 | [buymecoffee]: https://www.buymeacoffee.com/hnicolaisen 99 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 100 | [commits-shield]: https://img.shields.io/github/commit-activity/y/hmn/siku-integration.svg?style=for-the-badge 101 | [commits]: https://github.com/hmn/siku-integration/commits/main 102 | [hacs]: https://hacs.xyz 103 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 104 | [discord]: https://discord.gg/Qa5fW2R 105 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 106 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 107 | [forum]: https://community.home-assistant.io/ 108 | [license-shield]: https://img.shields.io/github/license/hmn/siku-integration.svg?style=for-the-badge 109 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40hmn-blue.svg?style=for-the-badge 110 | [pre-commit]: https://github.com/pre-commit/pre-commit 111 | [pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge 112 | [releases-shield]: https://img.shields.io/github/release/hmn/siku-integration.svg?style=for-the-badge 113 | [releases]: https://github.com/hmn/siku-integration/releases 114 | [user_profile]: https://github.com/hmn 115 | -------------------------------------------------------------------------------- /custom_components/siku/sensor.py: -------------------------------------------------------------------------------- 1 | """Siku fan sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import dataclasses 6 | import logging 7 | 8 | from homeassistant.components.sensor import ( 9 | SensorDeviceClass, 10 | SensorEntity, 11 | SensorEntityDescription, 12 | SensorStateClass, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import ( 16 | EntityCategory, 17 | UnitOfTime, 18 | REVOLUTIONS_PER_MINUTE, 19 | PERCENTAGE, 20 | ) 21 | from homeassistant.core import HomeAssistant, callback 22 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 23 | 24 | from . import SikuEntity 25 | from .const import DOMAIN 26 | from .coordinator import SikuDataUpdateCoordinator 27 | 28 | LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | @dataclasses.dataclass(frozen=True) 32 | class SikuSensorEntityDescription(SensorEntityDescription): 33 | """Describes Siku fan sensor entity.""" 34 | 35 | 36 | SENSORS: tuple[SikuSensorEntityDescription, ...] = ( 37 | SikuSensorEntityDescription( 38 | key="version", 39 | translation_key="version", 40 | icon="mdi:information", 41 | name="Version", 42 | entity_category=EntityCategory.DIAGNOSTIC, 43 | ), 44 | SikuSensorEntityDescription( 45 | key="humidity", 46 | name="Humidity", 47 | native_unit_of_measurement=PERCENTAGE, 48 | device_class=SensorDeviceClass.HUMIDITY, 49 | state_class=SensorStateClass.MEASUREMENT, 50 | ), 51 | SikuSensorEntityDescription( 52 | key="rpm", 53 | name="RPM", 54 | icon="mdi:rotate-right", 55 | native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, 56 | state_class=SensorStateClass.MEASUREMENT, 57 | ), 58 | SikuSensorEntityDescription( 59 | key="firmware", 60 | name="Firmware version", 61 | icon="mdi:information", 62 | entity_category=EntityCategory.DIAGNOSTIC, 63 | ), 64 | SikuSensorEntityDescription( 65 | key="alarm", 66 | name="Alarm", 67 | icon="mdi:alarm-light", 68 | ), 69 | SikuSensorEntityDescription( 70 | key="filter_timer_days", 71 | name="Filter timer countdown", 72 | icon="mdi:timer", 73 | native_unit_of_measurement=UnitOfTime.MINUTES, 74 | suggested_display_precision=0, 75 | suggested_unit_of_measurement=UnitOfTime.DAYS, 76 | device_class=SensorDeviceClass.DURATION, 77 | state_class=SensorStateClass.MEASUREMENT, 78 | ), 79 | SikuSensorEntityDescription( 80 | key="timer_countdown", 81 | name="Timer countdown", 82 | icon="mdi:timer", 83 | native_unit_of_measurement=UnitOfTime.SECONDS, 84 | suggested_display_precision=0, 85 | suggested_unit_of_measurement=UnitOfTime.MINUTES, 86 | device_class=SensorDeviceClass.DURATION, 87 | state_class=SensorStateClass.MEASUREMENT, 88 | ), 89 | SikuSensorEntityDescription( 90 | key="boost_mode_timer", 91 | name="Boost mode timer", 92 | icon="mdi:timer", 93 | native_unit_of_measurement=UnitOfTime.MINUTES, 94 | suggested_display_precision=0, 95 | suggested_unit_of_measurement=UnitOfTime.DAYS, 96 | device_class=SensorDeviceClass.DURATION, 97 | state_class=SensorStateClass.MEASUREMENT, 98 | ), 99 | SikuSensorEntityDescription( 100 | key="night_mode_timer", 101 | name="Sleep mode timer", 102 | icon="mdi:timer", 103 | native_unit_of_measurement=UnitOfTime.MINUTES, 104 | suggested_display_precision=0, 105 | suggested_unit_of_measurement=UnitOfTime.DAYS, 106 | device_class=SensorDeviceClass.DURATION, 107 | state_class=SensorStateClass.MEASUREMENT, 108 | ), 109 | SikuSensorEntityDescription( 110 | key="party_mode_timer", 111 | name="Party mode timer", 112 | icon="mdi:timer", 113 | native_unit_of_measurement=UnitOfTime.MINUTES, 114 | suggested_display_precision=0, 115 | suggested_unit_of_measurement=UnitOfTime.DAYS, 116 | device_class=SensorDeviceClass.DURATION, 117 | state_class=SensorStateClass.MEASUREMENT, 118 | ), 119 | SikuSensorEntityDescription( 120 | key="boost", 121 | name="Boost mode", 122 | icon="mdi:speedometer", 123 | ), 124 | SikuSensorEntityDescription( 125 | key="mode", 126 | name="Mode", 127 | icon="mdi:fan-auto", 128 | ), 129 | ) 130 | 131 | 132 | async def async_setup_entry( 133 | hass: HomeAssistant, 134 | entry: ConfigEntry, 135 | async_add_entities: AddEntitiesCallback, 136 | ) -> None: 137 | """Set up the Siku fan sensors.""" 138 | LOGGER.debug("Setting up Siku fan sensors %s", entry.entry_id) 139 | coordinator = hass.data[DOMAIN][entry.entry_id] 140 | available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()} 141 | # LOGGER.debug("Available resources : %s", available_resources) 142 | 143 | entities: list[SikuSensor] = [ 144 | SikuSensor(hass, coordinator, description) 145 | for description in SENSORS 146 | if description.key in available_resources 147 | ] 148 | 149 | # LOGGER.debug("Entities: %s", entities) 150 | 151 | async_add_entities(entities, True) 152 | 153 | 154 | class SikuSensor(SikuEntity, SensorEntity): 155 | """Representation of a Sensor.""" 156 | 157 | _attr_should_poll = True 158 | 159 | def __init__( 160 | self, 161 | hass: HomeAssistant, 162 | coordinator: SikuDataUpdateCoordinator, 163 | description: SensorEntityDescription, 164 | ) -> None: 165 | """Initialize the entity.""" 166 | super().__init__(coordinator=coordinator, context=description.key) 167 | self.hass = hass 168 | 169 | self.entity_description = description 170 | self._attr_device_info = coordinator.device_info 171 | self._attr_unique_id = ( 172 | f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-{description.key}" 173 | ) 174 | 175 | @callback 176 | def _handle_coordinator_update(self) -> None: 177 | """Handle updated data from the coordinator.""" 178 | self._update_attrs() 179 | super()._handle_coordinator_update() 180 | 181 | def _update_attrs(self) -> None: 182 | """Update sensor attributes based on coordinator data.""" 183 | key = self.entity_description.key 184 | self._attr_native_value = self.coordinator.data[key] 185 | LOGGER.debug("Native value [%s]: %s", key, self._attr_native_value) 186 | -------------------------------------------------------------------------------- /custom_components/siku/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Siku Fan integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | from collections.abc import Mapping 8 | 9 | import voluptuous as vol 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 12 | from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT 13 | from homeassistant.exceptions import HomeAssistantError 14 | from homeassistant.helpers import config_validation as cv 15 | 16 | from .api_v1 import SikuV1Api 17 | from .api_v2 import SikuV2Api 18 | from .const import CONF_ID, CONF_VERSION, DEFAULT_PORT, DOMAIN, DEFAULT_NAME 19 | 20 | USER_SCHEMA = vol.Schema( 21 | { 22 | vol.Required(CONF_IP_ADDRESS): cv.string, 23 | vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, 24 | vol.Required(CONF_VERSION, default=2): vol.In([1, 2]), 25 | vol.Optional(CONF_ID): cv.string, 26 | vol.Optional(CONF_PASSWORD): cv.string, 27 | } 28 | ) 29 | 30 | LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]): 34 | """Validate the user input allows us to connect. 35 | 36 | Data has the keys from USER_SCHEMA with values provided by the user. 37 | """ 38 | if data[CONF_VERSION] == 1: 39 | api = SikuV1Api(data[CONF_IP_ADDRESS], data[CONF_PORT]) 40 | elif data[CONF_VERSION] == 2: 41 | if CONF_ID not in data or CONF_PASSWORD not in data: 42 | raise ValueError("Invalid input") 43 | if len(data[CONF_ID]) != 16: 44 | raise InvalidInputFanId("Invalid idnum length must be 16 chars") 45 | if len(data[CONF_PASSWORD]) > 8: 46 | raise InvalidInputPassword("Invalid password max length 8 chars") 47 | api = SikuV2Api( 48 | data[CONF_IP_ADDRESS], data[CONF_PORT], data[CONF_ID], data[CONF_PASSWORD] 49 | ) 50 | else: 51 | raise ValueError("Invalid API version") 52 | 53 | try: 54 | if not await api.status(): 55 | raise CannotConnect 56 | except (ConnectionRefusedError, OSError) as err: 57 | raise CannotConnect from err 58 | 59 | 60 | class SikuConfigFlow(ConfigFlow, domain=DOMAIN): 61 | """Handle a config flow for Siku Fan.""" 62 | 63 | VERSION = 1 64 | MINOR_VERSION = 1 65 | 66 | async def async_step_user( 67 | self, user_input: dict[str, Any] | None = None 68 | ) -> ConfigFlowResult: 69 | """Handle the initial step.""" 70 | errors: dict[str, str] = {} 71 | if user_input is not None: 72 | host = user_input[CONF_IP_ADDRESS] 73 | port = user_input[CONF_PORT] 74 | await self.async_set_unique_id(f"{host}:{port}") 75 | self._abort_if_unique_id_configured() 76 | 77 | try: 78 | await validate_input(self.hass, user_input) 79 | title = f"{DEFAULT_NAME} {host}" 80 | return self.async_create_entry( 81 | title=title, 82 | data=user_input, 83 | ) 84 | except (ValueError, KeyError): 85 | errors["base"] = "value_error" 86 | except TimeoutError: 87 | errors["base"] = "timeout_error" 88 | except CannotConnect: 89 | errors["base"] = "cannot_connect" 90 | except InvalidAuth: 91 | errors["base"] = "invalid_auth" 92 | except InvalidInputFanId: 93 | errors["base"] = "invalid_idnum" 94 | except InvalidInputPassword: 95 | errors["base"] = "invalid_password" 96 | errors["password"] = "invalid_password" 97 | except Exception: # pylint: disable=broad-except 98 | LOGGER.exception("Unexpected exception") 99 | errors["base"] = "unknown" 100 | 101 | return self.async_show_form( 102 | step_id="user", 103 | data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), 104 | errors=errors, 105 | ) 106 | 107 | async def async_step_reauth( 108 | self, entry_data: Mapping[str, Any] 109 | ) -> ConfigFlowResult: 110 | """Perform reauth upon migration of old entries.""" 111 | return await self.async_step_user(dict(entry_data)) 112 | 113 | async def async_step_reconfigure( 114 | self, user_input: dict[str, Any] | None = None 115 | ) -> ConfigFlowResult: 116 | """Handle integration reconfiguration.""" 117 | return await self.async_step_reconfigure_confirm() 118 | 119 | async def async_step_reconfigure_confirm( 120 | self, user_input: dict[str, Any] | None = None 121 | ) -> ConfigFlowResult: 122 | """Handle integration reconfiguration.""" 123 | errors: dict[str, str] = {} 124 | if user_input is not None: 125 | try: 126 | await validate_input(self.hass, user_input) 127 | except (ValueError, KeyError): 128 | errors["base"] = "value_error" 129 | except TimeoutError: 130 | errors["base"] = "timeout_error" 131 | except CannotConnect: 132 | errors["base"] = "cannot_connect" 133 | except InvalidAuth: 134 | errors["base"] = "invalid_auth" 135 | except InvalidInputFanId: 136 | errors["base"] = "invalid_idnum" 137 | except InvalidInputPassword: 138 | errors["base"] = "invalid_password" 139 | errors["password"] = "invalid_password" 140 | except Exception: # pylint: disable=broad-except 141 | LOGGER.exception("Unexpected exception") 142 | errors["base"] = "unknown" 143 | else: 144 | host = user_input[CONF_IP_ADDRESS] 145 | port = user_input[CONF_PORT] 146 | await self.async_set_unique_id(f"{host}:{port}") 147 | self._abort_if_unique_id_configured() 148 | # Find the existing entry to update 149 | entry = next( 150 | ( 151 | e 152 | for e in self._async_current_entries() 153 | if e.unique_id == f"{host}:{port}" 154 | ), 155 | None, 156 | ) 157 | if entry is None: 158 | errors["base"] = "entry_not_found" 159 | else: 160 | return self.async_update_reload_and_abort( 161 | entry=entry, 162 | title=f"{DEFAULT_NAME} {host}", 163 | data=user_input, 164 | ) 165 | 166 | return self.async_show_form( 167 | step_id="reconfigure_confirm", 168 | data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), 169 | errors=errors, 170 | ) 171 | 172 | 173 | class CannotConnect(HomeAssistantError): 174 | """Error to indicate we cannot connect.""" 175 | 176 | 177 | class InvalidAuth(HomeAssistantError): 178 | """Error to indicate there is invalid auth.""" 179 | 180 | 181 | class InvalidInputFanId(HomeAssistantError): 182 | """Error to indicate there is invalid fan id defined.""" 183 | 184 | 185 | class InvalidInputPassword(HomeAssistantError): 186 | """Error to indicate there is invalid fan password defined.""" 187 | -------------------------------------------------------------------------------- /fake_fan/TESTING_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Testing the Fake Siku Fan Server 2 | 3 | ## Quick Start 4 | 5 | ### 1. Start the Fake Fan Server 6 | 7 | In one terminal: 8 | 9 | ```bash 10 | python fake_fan/fake_fan_server.py 11 | ``` 12 | 13 | You should see: 14 | 15 | ``` 16 | 2025-11-08 16:20:23,598 - INFO - Fake fan controller initialized 17 | 2025-11-08 16:20:23,598 - INFO - Device ID: 1234567890123456 18 | 2025-11-08 16:20:23,598 - INFO - Password: 1234 19 | 2025-11-08 16:20:23,598 - INFO - 20 | ============================================================ 21 | 2025-11-08 16:20:23,598 - INFO - Fake Siku Fan Server Started 22 | 2025-11-08 16:20:23,598 - INFO - Listening on 0.0.0.0:4000 23 | 2025-11-08 16:20:23,598 - INFO - ============================================================ 24 | 25 | 2025-11-08 16:20:23,598 - INFO - Waiting for commands... 26 | ``` 27 | 28 | ### 2. Run the Test Script 29 | 30 | In another terminal: 31 | 32 | ```bash 33 | python fake_fan/test_fake_fan.py 34 | ``` 35 | 36 | This will execute a series of test commands against the fake fan server. 37 | 38 | ### 3. Use with Home Assistant 39 | 40 | Start Home Assistant (if not already running): 41 | 42 | ```bash 43 | scripts/develop 44 | ``` 45 | 46 | Then add the Siku integration in the UI with these credentials: 47 | - **Host**: `localhost` 48 | - **Port**: `4000` 49 | - **Device ID**: `1234567890123456` 50 | - **Password**: `1234` 51 | 52 | ## What You'll See 53 | 54 | When you interact with the fan through Home Assistant or the test script, the fake fan server will log all traffic: 55 | 56 | ``` 57 | ============================================================ 58 | Connection from: 127.0.0.1:52134 59 | ============================================================ 60 | RECEIVED: FDFD02103132333435363738393031323334353637383930043132333401B9020244B74A6483860B2564 61 | Length: 40 bytes 62 | ✓ Checksum verified 63 | ✓ Authentication successful 64 | Function: 01 65 | Command: READ 66 | RESPONSE: FDFD02103132333435363738393031323334353637383930043132333406B9010203804A48250C... 67 | Length: 50 bytes 68 | ============================================================ 69 | 70 | ============================================================ 71 | Connection from: 127.0.0.1:52135 72 | ============================================================ 73 | RECEIVED: FDFD021031323334353637383930313233343536373839300431323334030101 74 | Length: 33 bytes 75 | ✓ Checksum verified 76 | ✓ Authentication successful 77 | Function: 03 78 | Command: READ_WRITE 79 | ✓ Fan turned ON 80 | RESPONSE: FDFD0210313233343536373839303132333435363738393004313233340601013F 81 | Length: 34 bytes 82 | ============================================================ 83 | ``` 84 | 85 | ## Traffic Legend 86 | 87 | Each transaction shows: 88 | 89 | 1. **Connection Info**: Source IP and port 90 | 2. **Received Packet**: The raw hex packet received 91 | 3. **Verification**: Checksum and authentication status 92 | 4. **Function**: The command type (READ, WRITE, READ_WRITE, etc.) 93 | 5. **Action**: What the server did (turned on, changed speed, etc.) 94 | 6. **Response**: The response packet sent back (if any) 95 | 96 | ## Protocol Functions 97 | 98 | The fake fan server supports all protocol functions: 99 | 100 | ### READ (0x01) 101 | Read current state without changing anything. 102 | 103 | ### WRITE (0x02) 104 | Change state, no response sent. 105 | 106 | ### READ_WRITE (0x03) 107 | Change state and return new state. 108 | 109 | ### INCREMENT (0x04) 110 | Increment a value (speed, etc.) 111 | 112 | ### DECREMENT (0x05) 113 | Decrement a value (speed, etc.) 114 | 115 | ## Simulated State 116 | 117 | The fake fan maintains realistic state: 118 | 119 | - **Power**: On/Off (starts OFF) 120 | - **Speed**: 1-10 preset speeds (starts at 3) 121 | - **Manual Speed**: 0-255 (starts at 128 = 50%) 122 | - **Direction**: Forward/Reverse/Alternating (starts Forward) 123 | - **Boost**: On/Off (starts OFF) 124 | - **Mode**: Auto/Sleep/Party (starts Auto) 125 | - **Humidity**: 45% (simulated sensor) 126 | - **RPM**: 1200 (simulated) 127 | - **Filter Timer**: Tracks minutes since last reset 128 | - **Countdown Timer**: Active countdown in seconds 129 | - **Alarm**: Filter alarm status 130 | - **Firmware**: Version 2.5 131 | 132 | ## Debugging Tips 133 | 134 | ### Enable Debug Mode 135 | 136 | For more detailed logging: 137 | 138 | ```bash 139 | python fake_fan/fake_fan_server.py --debug 140 | ``` 141 | 142 | ### Watch Traffic in Real-Time 143 | 144 | You can run the server and see all packets as they come in: 145 | 146 | ```bash 147 | python fake_fan/fake_fan_server.py | grep -E "RECEIVED|RESPONSE|✓" 148 | ``` 149 | 150 | ### Test Specific Commands 151 | 152 | You can write your own test scripts by importing the API: 153 | 154 | ```python 155 | from custom_components.siku.api_v2 import SikuV2Api 156 | 157 | api = SikuV2Api( 158 | host="127.0.0.1", 159 | port=4000, 160 | idnum="1234567890123456", 161 | password="1234" 162 | ) 163 | 164 | # Test commands 165 | await api.power_on() 166 | await api.speed("07") 167 | await api.direction("02") # alternating 168 | ``` 169 | 170 | ## Custom Configuration 171 | 172 | ### Different Port 173 | 174 | ```bash 175 | python fake_fan/fake_fan_server.py --port 5000 176 | ``` 177 | 178 | Then configure Home Assistant to use port 5000. 179 | 180 | ### Custom Credentials 181 | 182 | ```bash 183 | python fake_fan/fake_fan_server.py \ 184 | --id "mydevice123456789012" \ 185 | --password "secret123" 186 | ``` 187 | 188 | Then use these credentials in Home Assistant. 189 | 190 | ### Multiple Fans 191 | 192 | You can run multiple fake fans on different ports: 193 | 194 | Terminal 1: 195 | ```bash 196 | python fake_fan/fake_fan_server.py --port 4000 --id "bedroom123456789012" 197 | ``` 198 | 199 | Terminal 2: 200 | ```bash 201 | python fake_fan/fake_fan_server.py --port 4001 --id "living123456789012" 202 | ``` 203 | 204 | Then add two separate integrations in Home Assistant. 205 | 206 | ## Troubleshooting 207 | 208 | ### Connection Refused 209 | 210 | Make sure the fake fan server is running and listening on the correct port. 211 | 212 | ### Authentication Failed 213 | 214 | Check that the Device ID and Password match exactly (case-sensitive). 215 | 216 | ### No Response 217 | 218 | WRITE commands don't send responses - this is normal. Use READ_WRITE if you need confirmation. 219 | 220 | ### Port Already in Use 221 | 222 | Change the port: 223 | ```bash 224 | python fake_fan/fake_fan_server.py --port 4001 225 | ``` 226 | 227 | ## Integration with Home Assistant 228 | 229 | Once configured in Home Assistant, you can: 230 | 231 | 1. **View the fan entity** in the UI 232 | 2. **Control power** (on/off) 233 | 3. **Set speed** (1-10 or percentage) 234 | 4. **Change direction** (forward/reverse) 235 | 5. **Enable oscillation** (alternating mode) 236 | 6. **Activate preset modes** (sleep/party) 237 | 7. **View humidity** sensor 238 | 8. **Monitor RPM** sensor 239 | 9. **Check filter status** and reset 240 | 241 | All changes will be logged in the fake fan server terminal! 242 | 243 | ## Files Created 244 | 245 | - **`fake_fan/fake_fan_server.py`** - The main fake fan server 246 | - **`fake_fan/test_fake_fan.py`** - Test script to verify functionality 247 | - **`fake_fan/README.md`** - Documentation for the fake fan server 248 | - **`fake_fan/TESTING_GUIDE.md`** - This guide 249 | 250 | ## Next Steps 251 | 252 | 1. Start the fake fan server 253 | 2. Run the test script to verify it works 254 | 3. Add it to Home Assistant 255 | 4. Test all fan controls in the UI 256 | 5. Watch the traffic logs to understand the protocol 257 | 6. Develop and test new features without hardware! 258 | -------------------------------------------------------------------------------- /tests/test_api_v1.py: -------------------------------------------------------------------------------- 1 | """Tests for SikuV1Api.""" 2 | 3 | import asyncio 4 | import pytest 5 | from unittest.mock import AsyncMock, patch 6 | from custom_components.siku.api_v1 import ( 7 | FEEDBACK_PACKET_PREFIX, 8 | OperationMode, 9 | SikuV1Api, 10 | SpeedSelection, 11 | SpeedManual, 12 | Direction, 13 | Timer, 14 | OffOn, 15 | TimerSeconds, 16 | HumiditySensorThreshold, 17 | HumidityLevel, 18 | NoYes, 19 | NoYesYes, 20 | SPEED_MANUAL_MIN, 21 | ) 22 | 23 | # ruff: noqa: D103 24 | 25 | 26 | @pytest.fixture 27 | def api(): 28 | return SikuV1Api("127.0.0.1", 12345) 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_control_packet_valid(api): 33 | packet = await api._control_packet([("speed", SpeedSelection.LOW)]) 34 | assert isinstance(packet, bytes) 35 | assert packet.startswith(b"\x04") 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_control_packet_invalid_command(api): 40 | with pytest.raises(ValueError): 41 | await api._control_packet([("invalid", 1)]) 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_control_packet_invalid_value_type(api): 46 | with pytest.raises(TypeError): 47 | await api._control_packet([("speed", "not_an_enum")]) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_control_packet_none_value(api): 52 | packet = await api._control_packet([("status", None)]) 53 | assert isinstance(packet, bytes) 54 | assert packet.startswith(b"\x01") 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_send_command_success(api): 59 | fake_response = ( 60 | FEEDBACK_PACKET_PREFIX 61 | + b"\x03\x01\x04\x01\x05\x16\x06\x00\x08\x40\x09\x00\x11\x50\x0c\x00\x0d\x00\x0e\x00\x0f\x00\x10\x00\x11\x50\x12\x00\x13\x00\x14\x00\x00\x00\x15\x00\x00\x00\x16\x00\x00\x00\x17\x00\x00\x00\x18\x00\x19\x00\x1a\x00\x1b\x00\x1c\x00\x1d\x00\x1e\x00\x1f\x00\x20\x01\x21\x01\x22\x01\x23\x01\x25\x05\x26\x00\x27\x00" 62 | ) 63 | with patch.object(api._udp, "request", new=AsyncMock(return_value=fake_response)): 64 | result = await api._send_command(b"\x01\x00") 65 | assert isinstance(result, list) 66 | for item in result: 67 | assert isinstance(item, str) 68 | assert result[0] == "03" 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_send_command_timeout(api): 73 | with ( 74 | patch.object( 75 | api._udp, "request", new=AsyncMock(side_effect=asyncio.TimeoutError) 76 | ), 77 | pytest.raises(TimeoutError), 78 | ): 79 | await api._send_command(b"\x01\x00") 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_translate_response(api): 84 | # Simulate a response with status ON, speed HIGH, direction VENTILATION, etc. 85 | hexlist = [ 86 | "03", 87 | "01", # status: ON 88 | "04", 89 | "03", # speed: HIGH 90 | "05", 91 | "16", # manual_speed: 22 92 | "06", 93 | "00", # direction: VENTILATION 94 | "08", 95 | "40", # humidity_level: 64 96 | "09", 97 | "00", # operation_mode: REGULAR 98 | "0b", 99 | "50", # humidity_sensor_threshold: 80 100 | "12", 101 | "01", # filter_end_of_life: YES 102 | "14", 103 | "01", # boost_mode_after_sensor: YES 104 | "0e", 105 | "00", 106 | "00", 107 | "05", # countdown_timer: 10 sec 108 | ] 109 | result = await api._translate_response(hexlist) 110 | assert result["status"] == OffOn.ON 111 | assert result["speed"] == SpeedSelection.HIGH 112 | assert int(result["manual_speed"]) == int(SpeedManual(22)) 113 | assert result["direction"] == Direction.VENTILATION 114 | assert int(result["humidity_level"]) == int(HumidityLevel(64)) 115 | assert result["operation_mode"] == OperationMode.REGULAR 116 | assert int(result["humidity_sensor_threshold"]) == int(HumiditySensorThreshold(80)) 117 | assert result["filter_end_of_life"] == NoYes.YES 118 | assert result["boost_mode_after_sensor"] == NoYesYes.YES 119 | assert int(result["timer_countdown"]) == int(TimerSeconds(5)) 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_format_response(api): 124 | data = { 125 | "status": OffOn.ON, 126 | "speed": SpeedSelection.LOW, 127 | "manual_speed": SpeedManual(SPEED_MANUAL_MIN), 128 | "direction": Direction.HEAT_RECOVERY, 129 | "operation_mode": Timer.AUTO, 130 | "humidity_level": HumidityLevel(50), 131 | "filter_end_of_life": NoYes.NO, 132 | "timer_countdown": TimerSeconds(0), 133 | "boost_mode_after_sensor": NoYesYes.NO, 134 | "boost_mode_timer": TimerSeconds(0), 135 | "night_mode_timer": TimerSeconds(0), 136 | "party_mode_timer": TimerSeconds(0), 137 | } 138 | formatted = await api._format_response(data) 139 | assert formatted["is_on"] is True 140 | assert formatted["speed"] == SpeedSelection.LOW 141 | assert formatted["oscillating"] is True 142 | assert formatted["alarm"] is False 143 | assert formatted["version"] == "1" 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_power_on_off(api): 148 | # Patch _send_command and _translate_response to simulate device state 149 | with ( 150 | patch.object(api, "_send_command", new=AsyncMock(return_value=["03", "00"])), 151 | patch.object( 152 | api, 153 | "_translate_response", 154 | new=AsyncMock( 155 | return_value={ 156 | "status": OffOn.OFF, 157 | "speed": SpeedSelection.LOW, 158 | "manual_speed": SpeedManual(SPEED_MANUAL_MIN), 159 | "direction": Direction.VENTILATION, 160 | "operation_mode": Timer.AUTO, 161 | "humidity_level": HumidityLevel(50), 162 | "filter_end_of_life": NoYes.NO, 163 | "timer_countdown": TimerSeconds(0), 164 | "boost_mode_after_sensor": NoYesYes.NO, 165 | "boost_mode_timer": TimerSeconds(0), 166 | "night_mode_timer": TimerSeconds(0), 167 | "party_mode_timer": TimerSeconds(0), 168 | } 169 | ), 170 | ), 171 | patch.object( 172 | api, "_format_response", new=AsyncMock(return_value={"is_on": True}) 173 | ), 174 | ): 175 | result = await api.power_on() 176 | assert result["is_on"] is True 177 | 178 | with ( 179 | patch.object(api, "_send_command", new=AsyncMock(return_value=["03", "01"])), 180 | patch.object( 181 | api, 182 | "_translate_response", 183 | new=AsyncMock( 184 | return_value={ 185 | "status": OffOn.ON, 186 | "speed": SpeedSelection.LOW, 187 | "manual_speed": SpeedManual(SPEED_MANUAL_MIN), 188 | "direction": Direction.VENTILATION, 189 | "operation_mode": Timer.AUTO, 190 | "humidity_level": HumidityLevel(50), 191 | "filter_end_of_life": NoYes.NO, 192 | "timer_countdown": TimerSeconds(0), 193 | "boost_mode_after_sensor": NoYesYes.NO, 194 | "boost_mode_timer": TimerSeconds(0), 195 | "night_mode_timer": TimerSeconds(0), 196 | "party_mode_timer": TimerSeconds(0), 197 | } 198 | ), 199 | ), 200 | patch.object( 201 | api, "_format_response", new=AsyncMock(return_value={"is_on": False}) 202 | ), 203 | ): 204 | result = await api.power_off() 205 | assert result["is_on"] is False 206 | 207 | 208 | @pytest.mark.asyncio 209 | async def test_direction_invalid(api): 210 | with pytest.raises(ValueError): 211 | await api.direction("invalid_direction") 212 | -------------------------------------------------------------------------------- /tests/test_api_v2.py: -------------------------------------------------------------------------------- 1 | """Tests for SikuV2Api.""" 2 | 3 | import asyncio 4 | import pytest 5 | from unittest.mock import AsyncMock, patch 6 | from custom_components.siku.api_v2 import SPEED_MANUAL_MAX, SPEED_MANUAL_MIN, SikuV2Api 7 | from custom_components.siku.const import ( 8 | FAN_SPEEDS, 9 | DIRECTIONS, 10 | DIRECTION_FORWARD, 11 | DIRECTION_ALTERNATING, 12 | ) 13 | 14 | # ruff: noqa: D103 15 | 16 | 17 | @pytest.fixture 18 | def api(): 19 | return SikuV2Api("127.0.0.1", 12345, "1234567890abcdef", "pass1234") 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_status(api): 24 | with ( 25 | patch.object( 26 | api, 27 | "_send_command", 28 | new=AsyncMock(return_value=["FDFD", "02", "10", "12", "08", "06"]), 29 | ), 30 | patch.object( 31 | api, "_parse_response", new=AsyncMock(return_value={"01": "01", "02": "01"}) 32 | ), 33 | patch.object( 34 | api, 35 | "_translate_response", 36 | new=AsyncMock(return_value={"is_on": True, "speed": "01"}), 37 | ), 38 | ): 39 | result = await api.status() 40 | assert result["is_on"] is True 41 | assert result["speed"] == "01" 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_status_manual(api): 46 | with patch.object( 47 | api, 48 | "_send_command", 49 | new=AsyncMock( 50 | return_value=[ 51 | "FD", 52 | "FD", 53 | "02", 54 | "10", 55 | "30", 56 | "30", 57 | "32", 58 | "45", 59 | "30", 60 | "30", 61 | "32", 62 | "32", 63 | "35", 64 | "37", 65 | "34", 66 | "36", 67 | "35", 68 | "37", 69 | "30", 70 | "34", 71 | "08", 72 | "44", 73 | "65", 74 | "52", 75 | "6F", 76 | "6F", 77 | "73", 78 | "32", 79 | "34", 80 | "06", 81 | "FE", 82 | "02", 83 | "B9", 84 | "03", 85 | "00", 86 | "01", 87 | "01", 88 | "02", 89 | "FF", 90 | "44", 91 | "7C", 92 | "B7", 93 | "01", 94 | "06", 95 | "00", 96 | "07", 97 | "00", 98 | "FE", 99 | "03", 100 | "0B", 101 | "00", 102 | "00", 103 | "00", 104 | "25", 105 | "33", 106 | "FE", 107 | "02", 108 | "4A", 109 | "84", 110 | "03", 111 | "FE", 112 | "04", 113 | "64", 114 | "2F", 115 | "0F", 116 | "55", 117 | "00", 118 | "83", 119 | "00", 120 | "FE", 121 | "06", 122 | "86", 123 | "00", 124 | "09", 125 | "08", 126 | "07", 127 | "E8", 128 | "07", 129 | "9C", 130 | "11", 131 | ] 132 | ), 133 | ): 134 | result = await api.status() 135 | assert result["is_on"] is True 136 | assert result["speed"] == "255" 137 | assert result["manual_speed_selected"] is True 138 | # check that manual speed is in range and is equal to the calculated value 49% 139 | assert result["manual_speed"] >= SPEED_MANUAL_MIN 140 | assert result["manual_speed"] <= SPEED_MANUAL_MAX 141 | assert result["manual_speed"] == int(SPEED_MANUAL_MAX / 100 * 49) 142 | assert result["manual_speed_low_high_range"] == ( 143 | float(SPEED_MANUAL_MIN), 144 | float(SPEED_MANUAL_MAX), 145 | ) 146 | assert result["oscillating"] is False 147 | assert result["direction"] == "alternating" 148 | assert result["boost"] is False 149 | assert result["mode"] == "auto" 150 | assert result["humidity"] == 51 151 | assert result["rpm"] == 900 152 | assert result["firmware"] == "0.7" 153 | assert result["filter_timer_days"] == 5115 154 | assert result["timer_countdown"] == 0 155 | assert result["alarm"] is False 156 | assert result["version"] == "2" 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_power_on(api): 161 | with ( 162 | patch.object(api, "_send_command", new=AsyncMock()), 163 | patch.object(api, "status", new=AsyncMock(return_value={"is_on": True})), 164 | ): 165 | result = await api.power_on() 166 | assert result["is_on"] is True 167 | 168 | 169 | @pytest.mark.asyncio 170 | async def test_power_off(api): 171 | with ( 172 | patch.object(api, "_send_command", new=AsyncMock()), 173 | patch.object(api, "status", new=AsyncMock(return_value={"is_on": False})), 174 | ): 175 | result = await api.power_off() 176 | assert result["is_on"] is False 177 | 178 | 179 | @pytest.mark.asyncio 180 | async def test_speed_valid(api): 181 | with ( 182 | patch.object(api, "_send_command", new=AsyncMock()), 183 | patch.object( 184 | api, "status", new=AsyncMock(return_value={"speed": FAN_SPEEDS[0]}) 185 | ), 186 | ): 187 | result = await api.speed(FAN_SPEEDS[0]) 188 | assert result["speed"] == FAN_SPEEDS[0] 189 | 190 | 191 | @pytest.mark.asyncio 192 | async def test_speed_invalid(api): 193 | with pytest.raises(ValueError): 194 | await api.speed("invalid") 195 | 196 | 197 | @pytest.mark.asyncio 198 | async def test_speed_manual(api): 199 | with ( 200 | patch.object(api, "_send_command", new=AsyncMock()), 201 | patch.object(api, "status", new=AsyncMock(return_value={"manual_speed": 100})), 202 | ): 203 | result = await api.speed_manual("50") 204 | assert "manual_speed" in result 205 | 206 | 207 | @pytest.mark.asyncio 208 | async def test_speed_manual_hex_formatting_bug(api): 209 | """Test that speed_manual correctly formats the speed value as hexadecimal. 210 | 211 | This test verifies the fix for the bug where integer speed values were incorrectly 212 | concatenated as decimal strings instead of being formatted as hex. 213 | For example, speed 123 (decimal) should be formatted as "7B" (hex). 214 | The command should be "447B" (44 is COMMAND_MANUAL_SPEED, 7B is hex for 123). 215 | """ 216 | with ( 217 | patch.object(api, "_send_command", new=AsyncMock()) as mock_send, 218 | patch.object(api, "status", new=AsyncMock(return_value={"manual_speed": 123})), 219 | ): 220 | # 48.6% of 255 ≈ 123, which should be formatted as hex "7B" 221 | result = await api.speed_manual(48.6) 222 | 223 | # Verify that _send_command was called with the correct hex-formatted command 224 | mock_send.assert_called_once() 225 | call_args = mock_send.call_args[0] 226 | command_data = call_args[1] # Second argument is the data 227 | 228 | # The command should be "447B" (COMMAND_MANUAL_SPEED + hex(123)) 229 | # not "44123" (COMMAND_MANUAL_SPEED + decimal 123) 230 | assert command_data == "02FF447B", ( 231 | f"Expected '02FF447B' but got: {command_data}" 232 | ) 233 | 234 | # Verify the result 235 | assert result["manual_speed"] == 123 236 | 237 | 238 | @pytest.mark.asyncio 239 | async def test_direction_valid(api): 240 | with ( 241 | patch.object(api, "_send_command", new=AsyncMock()), 242 | patch.object( 243 | api, 244 | "status", 245 | new=AsyncMock(return_value={"direction": DIRECTIONS[DIRECTION_FORWARD]}), 246 | ), 247 | ): 248 | result = await api.direction(DIRECTION_FORWARD) 249 | assert result["direction"] == DIRECTIONS[DIRECTION_FORWARD] 250 | 251 | 252 | @pytest.mark.asyncio 253 | async def test_direction_invalid(api): 254 | with pytest.raises(ValueError): 255 | await api.direction("invalid") 256 | 257 | 258 | @pytest.mark.asyncio 259 | async def test_direction_value_translation(api): 260 | # Test passing the value instead of the key 261 | with ( 262 | patch.object(api, "_send_command", new=AsyncMock()), 263 | patch.object( 264 | api, 265 | "status", 266 | new=AsyncMock( 267 | return_value={"direction": DIRECTIONS[DIRECTION_ALTERNATING]} 268 | ), 269 | ), 270 | ): 271 | result = await api.direction(DIRECTIONS[DIRECTION_ALTERNATING]) 272 | assert result["direction"] == DIRECTIONS[DIRECTION_ALTERNATING] 273 | 274 | 275 | @pytest.mark.asyncio 276 | async def test_sleep(api): 277 | with ( 278 | patch.object(api, "_send_command", new=AsyncMock()), 279 | patch.object(api, "status", new=AsyncMock(return_value={"mode": "sleep"})), 280 | ): 281 | result = await api.sleep() 282 | assert result["mode"] == "sleep" 283 | 284 | 285 | @pytest.mark.asyncio 286 | async def test_party(api): 287 | with ( 288 | patch.object(api, "_send_command", new=AsyncMock()), 289 | patch.object(api, "status", new=AsyncMock(return_value={"mode": "party"})), 290 | ): 291 | result = await api.party() 292 | assert result["mode"] == "party" 293 | 294 | 295 | @pytest.mark.asyncio 296 | async def test_reset_filter_alarm(api): 297 | with ( 298 | patch.object(api, "_send_command", new=AsyncMock()), 299 | patch.object(api, "status", new=AsyncMock(return_value={"alarm": False})), 300 | ): 301 | result = await api.reset_filter_alarm() 302 | assert result["alarm"] is False 303 | 304 | 305 | @pytest.mark.asyncio 306 | async def test_send_command_timeout(api): 307 | with ( 308 | patch.object( 309 | api._udp, "request", new=AsyncMock(side_effect=asyncio.TimeoutError) 310 | ), 311 | pytest.raises(TimeoutError), 312 | ): 313 | await api._send_command("01", "deadbeef") 314 | 315 | 316 | @pytest.mark.asyncio 317 | async def test_send_command_checksum_error(api): 318 | # Patch _verify_checksum to return False 319 | with ( 320 | patch.object(api._udp, "request", new=AsyncMock(return_value=b"deadbeef")), 321 | patch.object(api, "_verify_checksum", return_value=False), 322 | pytest.raises(ValueError), 323 | ): 324 | await api._send_command("01", "deadbeef") 325 | 326 | 327 | def test_checksum_and_hexlist(api): 328 | # Test _checksum and _hexlist helpers 329 | data = "AABBCCDD" 330 | hexlist = api._hexlist(data) 331 | assert hexlist == ["AA", "BB", "CC", "DD"] 332 | checksum = api._checksum(data) 333 | assert isinstance(checksum, str) 334 | assert len(checksum) == 4 335 | 336 | 337 | def test_build_packet(api): 338 | # Test _build_packet helper 339 | packet = api._build_packet("01", "AABB") 340 | assert isinstance(packet, str) 341 | assert packet.startswith("FDFD") 342 | assert len(packet) > 10 343 | -------------------------------------------------------------------------------- /custom_components/siku/fan.py: -------------------------------------------------------------------------------- 1 | """Siku fan.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.fan import FanEntity 9 | from homeassistant.components.fan import FanEntityFeature 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import callback 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.util.percentage import ordered_list_item_to_percentage 15 | from homeassistant.util.percentage import percentage_to_ordered_list_item 16 | from homeassistant.util.percentage import ranged_value_to_percentage 17 | 18 | from . import SikuEntity 19 | from .const import DEFAULT_NAME, DIRECTION_FORWARD, DIRECTIONS 20 | from .const import DOMAIN 21 | from .const import FAN_SPEEDS 22 | from .const import PRESET_MODE_AUTO 23 | from .const import PRESET_MODE_MANUAL 24 | from .const import PRESET_MODE_ON 25 | from .const import PRESET_MODE_PARTY 26 | from .const import PRESET_MODE_SLEEP 27 | from .const import DIRECTION_ALTERNATING 28 | from .coordinator import SikuDataUpdateCoordinator 29 | 30 | LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ) -> None: 38 | """Set up the Siku fan.""" 39 | LOGGER.debug("Setting up Siku fan") 40 | LOGGER.debug("Entry: %s", entry.entry_id) 41 | coordinator = hass.data[DOMAIN][entry.entry_id] 42 | async_add_entities( 43 | [ 44 | SikuFan( 45 | hass=hass, 46 | coordinator=coordinator, 47 | # entry=entry, 48 | # unique_id=f"{entry.entry_id}", 49 | # name=f"{DEFAULT_NAME} {entry.data[CONF_IP_ADDRESS]}", 50 | ) 51 | ], 52 | True, 53 | ) 54 | 55 | 56 | class SikuFan(SikuEntity, FanEntity): 57 | """Siku Fan.""" 58 | 59 | _attr_supported_features = ( 60 | FanEntityFeature.SET_SPEED 61 | | FanEntityFeature.OSCILLATE 62 | | FanEntityFeature.DIRECTION 63 | | FanEntityFeature.PRESET_MODE 64 | | FanEntityFeature.TURN_ON 65 | | FanEntityFeature.TURN_OFF 66 | ) 67 | _attr_preset_modes = [ 68 | PRESET_MODE_AUTO, 69 | PRESET_MODE_MANUAL, 70 | PRESET_MODE_ON, 71 | PRESET_MODE_PARTY, 72 | PRESET_MODE_SLEEP, 73 | ] 74 | _attr_should_poll = True 75 | 76 | def __init__( 77 | self, 78 | hass: HomeAssistant, 79 | coordinator: SikuDataUpdateCoordinator, 80 | ) -> None: 81 | """Initialize the entity.""" 82 | super().__init__(coordinator) 83 | 84 | self.hass = hass 85 | self._attr_name = f"{DEFAULT_NAME} {coordinator.api.host}" 86 | self._attr_device_info = coordinator.device_info 87 | self._attr_unique_id = ( 88 | f"{DOMAIN}-{coordinator.api.host}-{coordinator.api.port}-fan" 89 | ) 90 | 91 | @property 92 | def speed_count(self) -> int: 93 | """Return the number of speeds the fan supports.""" 94 | if ( 95 | self._attr_preset_mode == PRESET_MODE_MANUAL 96 | or self.coordinator.data["manual_speed_selected"] 97 | ): 98 | return 100 # Manual speed supports 1-100 99 | return len(FAN_SPEEDS) 100 | 101 | def set_percentage(self, percentage: int) -> None: 102 | """Set the speed of the fan, as a percentage.""" 103 | LOGGER.debug("Setting percentage to %s", percentage) 104 | self._attr_percentage = percentage 105 | if percentage == 0: 106 | self.set_preset_mode(None) 107 | 108 | async def async_set_percentage(self, percentage: int) -> None: 109 | """Set the speed of the fan, as a percentage.""" 110 | LOGGER.debug( 111 | "Async setting percentage to %s preset mode %s %s", 112 | percentage, 113 | self._attr_preset_mode, 114 | self.coordinator.data["manual_speed_selected"], 115 | ) 116 | if percentage == 0: 117 | await self.coordinator.api.power_off() 118 | if ( 119 | self._attr_preset_mode != PRESET_MODE_MANUAL 120 | and self.coordinator.data["manual_speed_selected"] 121 | ): 122 | await self.hass.async_add_executor_job(self.set_preset_mode, None) 123 | else: 124 | await self.coordinator.api.power_on() 125 | if ( 126 | self.coordinator.data["manual_speed_selected"] 127 | and self.coordinator.data["manual_speed"] 128 | ): 129 | await self.coordinator.api.speed_manual(percentage) 130 | elif self._attr_preset_mode == PRESET_MODE_MANUAL: 131 | await self.coordinator.api.speed_manual(percentage) 132 | elif self.coordinator.data["speed_list"]: 133 | await self.coordinator.api.speed( 134 | percentage_to_ordered_list_item( 135 | self.coordinator.data["speed_list"], percentage 136 | ) 137 | ) 138 | else: 139 | await self.coordinator.api.speed( 140 | percentage_to_ordered_list_item(FAN_SPEEDS, percentage) 141 | ) 142 | # did any of the preset modes change? 143 | if ( 144 | self.coordinator.data["manual_speed_selected"] 145 | or self._attr_preset_mode == PRESET_MODE_MANUAL 146 | ): 147 | await self.hass.async_add_executor_job( 148 | self.set_preset_mode, PRESET_MODE_MANUAL 149 | ) 150 | elif self.oscillating: 151 | await self.hass.async_add_executor_job( 152 | self.set_preset_mode, PRESET_MODE_AUTO 153 | ) 154 | else: 155 | await self.hass.async_add_executor_job( 156 | self.set_preset_mode, PRESET_MODE_ON 157 | ) 158 | await self.hass.async_add_executor_job(self.set_percentage, percentage) 159 | self.async_write_ha_state() 160 | 161 | def oscillate(self, oscillating: bool) -> None: 162 | """Oscillate the fan.""" 163 | self._attr_oscillating = oscillating 164 | if oscillating: 165 | self.set_direction(None) 166 | 167 | async def async_oscillate(self, oscillating: bool) -> None: 168 | """Set oscillation.""" 169 | self._attr_oscillating = oscillating 170 | if oscillating: 171 | if not self.is_on: 172 | await self.async_turn_on() 173 | await self.coordinator.api.direction(DIRECTION_ALTERNATING) 174 | preset_mode = PRESET_MODE_AUTO 175 | else: 176 | await self.coordinator.api.direction(DIRECTION_FORWARD) 177 | preset_mode = PRESET_MODE_ON 178 | if self.coordinator.data["manual_speed_selected"]: 179 | preset_mode = PRESET_MODE_MANUAL 180 | await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) 181 | await self.hass.async_add_executor_job(self.oscillate, oscillating) 182 | self.async_write_ha_state() 183 | 184 | def set_direction(self, direction: str | None) -> None: 185 | """Set the direction of the fan.""" 186 | self._attr_current_direction = direction 187 | 188 | async def async_set_direction(self, direction: str) -> None: 189 | """Set the direction of the fan.""" 190 | await self.coordinator.api.direction(direction) 191 | await self.hass.async_add_executor_job(self.set_direction, direction) 192 | if self.oscillating: 193 | await self.hass.async_add_executor_job(self.oscillate, False) 194 | if self.coordinator.data["manual_speed_selected"]: 195 | await self.hass.async_add_executor_job( 196 | self.set_preset_mode, PRESET_MODE_MANUAL 197 | ) 198 | else: 199 | await self.hass.async_add_executor_job(self.set_preset_mode, PRESET_MODE_ON) 200 | self.async_write_ha_state() 201 | 202 | async def async_turn_on( 203 | self, 204 | percentage: int | None = None, 205 | preset_mode: str | None = None, 206 | **kwargs: Any, 207 | ) -> None: 208 | """Turn on the entity.""" 209 | LOGGER.debug( 210 | "Turning on fan with percentage %s and preset mode %s : %s", 211 | percentage, 212 | preset_mode, 213 | kwargs, 214 | ) 215 | if percentage is None: 216 | percentage = ordered_list_item_to_percentage(FAN_SPEEDS, FAN_SPEEDS[0]) 217 | await self.async_set_percentage(percentage) 218 | self.async_write_ha_state() 219 | 220 | async def async_turn_off(self, **kwargs: Any) -> None: 221 | """Turn off the entity.""" 222 | await self.async_set_percentage(0) 223 | self.async_write_ha_state() 224 | 225 | def set_preset_mode(self, preset_mode: str | None) -> None: 226 | """Set the preset mode of the fan.""" 227 | self._attr_preset_mode = preset_mode 228 | self.schedule_update_ha_state() 229 | 230 | async def async_set_preset_mode(self, preset_mode: str | None) -> None: 231 | """Set new preset mode.""" 232 | if preset_mode == PRESET_MODE_PARTY: 233 | LOGGER.debug("Setting preset mode to party from %s", self._attr_preset_mode) 234 | await self.coordinator.api.power_on() 235 | response = await self.coordinator.api.party() 236 | if response: 237 | self.coordinator.async_set_updated_data(response) 238 | elif preset_mode == PRESET_MODE_SLEEP: 239 | LOGGER.debug("Setting preset mode to sleep from %s", self._attr_preset_mode) 240 | await self.coordinator.api.power_on() 241 | response = await self.coordinator.api.sleep() 242 | if response: 243 | self.coordinator.async_set_updated_data(response) 244 | elif preset_mode == PRESET_MODE_AUTO: 245 | LOGGER.debug("Setting preset mode to auto from %s", self._attr_preset_mode) 246 | await self.coordinator.api.power_on() 247 | response = await self.coordinator.api.speed(FAN_SPEEDS[0]) 248 | if response: 249 | self.coordinator.async_set_updated_data(response) 250 | await self.async_oscillate(True) 251 | elif preset_mode == PRESET_MODE_ON: 252 | LOGGER.debug("Setting preset mode to on from %s", self._attr_preset_mode) 253 | await self.coordinator.api.power_on() 254 | response = await self.coordinator.api.speed(FAN_SPEEDS[0]) 255 | if response: 256 | self.coordinator.async_set_updated_data(response) 257 | await self.async_set_direction(DIRECTIONS[DIRECTION_FORWARD]) 258 | elif preset_mode == PRESET_MODE_MANUAL: 259 | LOGGER.debug( 260 | "Setting preset mode to manual from %s", self._attr_preset_mode 261 | ) 262 | await self.coordinator.api.power_on() 263 | percentage = ordered_list_item_to_percentage(FAN_SPEEDS, FAN_SPEEDS[0]) 264 | response = await self.coordinator.api.speed_manual(percentage) 265 | if response: 266 | self.coordinator.async_set_updated_data(response) 267 | await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) 268 | self.async_write_ha_state() 269 | 270 | async def async_added_to_hass(self) -> None: 271 | """When entity is added to hass.""" 272 | await super().async_added_to_hass() 273 | self._handle_coordinator_update() 274 | 275 | @callback 276 | def _handle_coordinator_update(self) -> None: 277 | """Handle updated data from the coordinator.""" 278 | LOGGER.debug("Handling coordinator update %s", self.coordinator.data) 279 | if self.coordinator.data is None: 280 | return 281 | if self.coordinator.data["is_on"]: 282 | if self.coordinator.data["manual_speed_selected"]: 283 | LOGGER.debug( 284 | "Setting manual speed from selection %s and speed %s", 285 | self.coordinator.data["manual_speed_selected"], 286 | self.coordinator.data["manual_speed"], 287 | ) 288 | self.set_percentage( 289 | ranged_value_to_percentage( 290 | self.coordinator.data["manual_speed_low_high_range"], 291 | self.coordinator.data["manual_speed"], 292 | ) 293 | ) 294 | self.set_preset_mode(PRESET_MODE_MANUAL) 295 | elif self.coordinator.data["speed_list"]: 296 | LOGGER.debug( 297 | "Setting percentage from speed %s", self.coordinator.data["speed"] 298 | ) 299 | LOGGER.debug( 300 | "Setting percentage from speed %s", 301 | self.coordinator.data["speed_list"], 302 | ) 303 | LOGGER.debug( 304 | "Setting percentage from speed type %s", 305 | type(self.coordinator.data["speed"]), 306 | ) 307 | self.set_percentage( 308 | ordered_list_item_to_percentage( 309 | self.coordinator.data["speed_list"], 310 | self.coordinator.data["speed"], 311 | ) 312 | ) 313 | if not self.coordinator.data["oscillating"] and self.coordinator.data[ 314 | "direction" 315 | ] != int(DIRECTION_ALTERNATING): 316 | self.set_preset_mode(PRESET_MODE_ON) 317 | else: 318 | self.set_preset_mode(PRESET_MODE_AUTO) 319 | else: 320 | self.set_percentage( 321 | ordered_list_item_to_percentage( 322 | FAN_SPEEDS, self.coordinator.data["speed"] 323 | ) 324 | ) 325 | if not self.coordinator.data["oscillating"] and self.coordinator.data[ 326 | "direction" 327 | ] != int(DIRECTION_ALTERNATING): 328 | self.set_preset_mode(PRESET_MODE_ON) 329 | else: 330 | self.set_preset_mode(PRESET_MODE_AUTO) 331 | else: 332 | self.set_percentage(0) 333 | if not self.coordinator.data["oscillating"] and self.coordinator.data[ 334 | "direction" 335 | ] != int(DIRECTION_ALTERNATING): 336 | self.oscillate(False) 337 | if isinstance(self.coordinator.data["direction"], int): 338 | direction = f"{self.coordinator.data['direction']:02}" 339 | else: 340 | direction = self.coordinator.data["direction"] 341 | mapped_direction = DIRECTIONS.get(direction, DIRECTIONS[DIRECTION_FORWARD]) 342 | self.set_direction(mapped_direction) 343 | else: 344 | self.oscillate(True) 345 | 346 | super()._handle_coordinator_update() 347 | -------------------------------------------------------------------------------- /fake_fan/fake_fan_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Fake Siku Fan Controller Server for Testing. 3 | 4 | This script simulates a Siku fan controller that responds to UDP commands 5 | according to the protocol specification. It can be used for testing the 6 | Home Assistant integration without requiring physical hardware. 7 | 8 | Usage: 9 | python fake_fan_server.py [--host HOST] [--port PORT] [--id ID] [--password PASSWORD] 10 | 11 | Example: 12 | python fake_fan_server.py --host 0.0.0.0 --port 4000 --id "1234567890123456" --password "1234" 13 | 14 | """ 15 | 16 | import argparse 17 | import logging 18 | import random 19 | import socket 20 | import time 21 | from datetime import datetime 22 | 23 | # Protocol constants 24 | PACKET_PREFIX = "FDFD" 25 | PACKET_PROTOCOL_TYPE = "02" 26 | 27 | FUNC_READ = "01" 28 | FUNC_WRITE = "02" 29 | FUNC_READ_WRITE = "03" 30 | FUNC_INC = "04" 31 | FUNC_DEC = "05" 32 | FUNC_RESULT = "06" 33 | 34 | RETURN_CHANGE_FUNC = "FC" 35 | RETURN_INVALID = "FD" 36 | RETURN_VALUE_SIZE = "FE" 37 | RETURN_HIGH_BYTE = "FF" 38 | 39 | COMMAND_ON_OFF = "01" 40 | COMMAND_SPEED = "02" 41 | COMMAND_BOOST = "06" 42 | COMMAND_MODE = "07" 43 | COMMAND_TIMER_COUNTDOWN = "0B" 44 | COMMAND_CURRENT_HUMIDITY = "25" 45 | COMMAND_MANUAL_SPEED = "44" 46 | COMMAND_FAN1RPM = "4A" 47 | COMMAND_FILTER_TIMER = "64" 48 | COMMAND_RESET_FILTER_TIMER = "65" 49 | COMMAND_SEARCH = "7C" 50 | COMMAND_RUN_HOURS = "7E" 51 | COMMAND_RESET_ALARMS = "80" 52 | COMMAND_READ_ALARM = "83" 53 | COMMAND_READ_FIRMWARE_VERSION = "86" 54 | COMMAND_FILTER_ALARM = "88" 55 | COMMAND_DIRECTION = "B7" 56 | COMMAND_DEVICE_TYPE = "B9" 57 | 58 | POWER_OFF = "00" 59 | POWER_ON = "01" 60 | POWER_TOGGLE = "02" 61 | 62 | MODE_OFF = "01" 63 | MODE_SLEEP = "01" 64 | MODE_PARTY = "02" 65 | 66 | # Setup logging 67 | logging.basicConfig( 68 | level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" 69 | ) 70 | LOGGER = logging.getLogger(__name__) 71 | 72 | 73 | class FakeFanController: 74 | """Simulates a Siku fan controller.""" 75 | 76 | def __init__(self, device_id: str, password: str, slow_mode: bool = False): 77 | """Initialize the fake fan controller.""" 78 | self.device_id = device_id 79 | self.password = password 80 | self.slow_mode = slow_mode 81 | 82 | # Fan state 83 | self.is_on = False 84 | self.speed = "01" # Speed 1-3 (255 = manual) 85 | self.manual_speed = "80" # Manual speed 0-255 (128 = ~50%) 86 | self.direction = "00" # 00=forward, 01=reverse, 02=alternating 87 | self.boost = False 88 | self.mode = MODE_OFF # 01=sleep, 02=party 89 | self.humidity = 45 # Current humidity percentage 90 | self.rpm = 1200 # Fan RPM 91 | self.filter_timer_minutes = 0 # Minutes since filter change 92 | self.timer_countdown_seconds = 0 # Countdown timer in seconds 93 | self.alarm = False 94 | self.firmware_major = 2 95 | self.firmware_minor = 5 96 | self.device_type = "01" 97 | 98 | LOGGER.info("Fake fan controller initialized") 99 | LOGGER.info(f" Device ID: {self.device_id}") 100 | LOGGER.info(f" Password: {self.password}") 101 | if self.slow_mode: 102 | LOGGER.info(" Slow mode: ENABLED (1-7 second delays)") 103 | 104 | def _checksum(self, data: str) -> str: 105 | """Calculate checksum for packet.""" 106 | hexlist = [data[i : i + 2] for i in range(0, len(data), 2)] 107 | checksum = 0 108 | for hexstr in hexlist[2:]: 109 | checksum += int(hexstr, 16) 110 | checksum_str = f"{checksum:04X}" 111 | return f"{checksum_str[2:4]}{checksum_str[0:2]}" 112 | 113 | def _verify_checksum(self, hexlist: list) -> bool: 114 | """Verify checksum of received packet.""" 115 | data = "".join(hexlist[0:-2]) 116 | checksum = self._checksum(data) 117 | received_checksum = hexlist[-2] + hexlist[-1] 118 | return checksum == received_checksum 119 | 120 | def _verify_auth(self, hexlist: list) -> bool: 121 | """Verify device ID and password in packet.""" 122 | try: 123 | # Check prefix 124 | if hexlist[0] + hexlist[1] != PACKET_PREFIX: 125 | return False 126 | 127 | # Check protocol type 128 | if hexlist[2] != PACKET_PROTOCOL_TYPE: 129 | return False 130 | 131 | # Get ID length and ID 132 | id_length = int(hexlist[3], 16) 133 | id_hex = "".join(hexlist[4 : 4 + id_length]) 134 | received_id = bytes.fromhex(id_hex).decode("utf-8") 135 | 136 | # Get password length and password 137 | pwd_start = 4 + id_length 138 | pwd_length = int(hexlist[pwd_start], 16) 139 | pwd_hex = "".join(hexlist[pwd_start + 1 : pwd_start + 1 + pwd_length]) 140 | received_password = bytes.fromhex(pwd_hex).decode("utf-8") 141 | 142 | return received_id == self.device_id and received_password == self.password 143 | except (IndexError, ValueError) as e: 144 | LOGGER.error(f"Auth verification error: {e}") 145 | return False 146 | 147 | def _build_response_header(self) -> str: 148 | """Build response packet header with auth info.""" 149 | id_hex = self.device_id.encode("utf-8").hex().upper() 150 | id_length = f"{len(self.device_id):02X}" 151 | password_hex = self.password.encode("utf-8").hex().upper() 152 | password_length = f"{len(self.password):02X}" 153 | 154 | header = ( 155 | PACKET_PREFIX 156 | + PACKET_PROTOCOL_TYPE 157 | + id_length 158 | + id_hex 159 | + password_length 160 | + password_hex 161 | + FUNC_RESULT 162 | ) 163 | return header 164 | 165 | def _get_state_value(self, command: str) -> tuple[str, bool]: 166 | """Get current state value for a command. 167 | 168 | Returns: 169 | tuple: (value_string, is_multibyte) 170 | 171 | """ 172 | if command == COMMAND_ON_OFF: 173 | return (POWER_ON if self.is_on else POWER_OFF, False) 174 | elif command == COMMAND_SPEED: 175 | return (self.speed, False) 176 | elif command == COMMAND_MANUAL_SPEED: 177 | return (self.manual_speed, False) 178 | elif command == COMMAND_DIRECTION: 179 | return (self.direction, False) 180 | elif command == COMMAND_BOOST: 181 | return ("01" if self.boost else "00", False) 182 | elif command == COMMAND_MODE: 183 | return (self.mode, False) 184 | elif command == COMMAND_CURRENT_HUMIDITY: 185 | return (f"{self.humidity:02X}", False) 186 | elif command == COMMAND_FAN1RPM: 187 | # RPM can be larger than 255, so use multi-byte for values > 255 188 | if self.rpm > 255: 189 | # Multi-byte value: size + command + data (2 bytes for RPM, big-endian) 190 | return ( 191 | f"02{command}{(self.rpm >> 8):02X}{(self.rpm & 0xFF):02X}", 192 | True, 193 | ) 194 | else: 195 | return (f"{self.rpm:02X}", False) 196 | elif command == COMMAND_FILTER_TIMER: 197 | # Return as 3 bytes: days, hours, minutes 198 | days = self.filter_timer_minutes // (24 * 60) 199 | remaining = self.filter_timer_minutes % (24 * 60) 200 | hours = remaining // 60 201 | minutes = remaining % 60 202 | # Multi-byte value: FE + size + command + data 203 | return (f"03{command}{days:02X}{hours:02X}{minutes:02X}", True) 204 | elif command == COMMAND_TIMER_COUNTDOWN: 205 | # Return as 3 bytes: hours, minutes, seconds 206 | hours = self.timer_countdown_seconds // 3600 207 | remaining = self.timer_countdown_seconds % 3600 208 | minutes = remaining // 60 209 | seconds = remaining % 60 210 | # Multi-byte value: FE + size + command + data 211 | return (f"03{command}{hours:02X}{minutes:02X}{seconds:02X}", True) 212 | elif command == COMMAND_READ_ALARM: 213 | return ("01" if self.alarm else "00", False) 214 | elif command == COMMAND_READ_FIRMWARE_VERSION: 215 | # Return firmware version as multi-byte value 216 | now = datetime.now() 217 | value = ( 218 | f"06{command}{self.firmware_major:02X}" 219 | f"{self.firmware_minor:02X}{now.day:02X}{now.month:02X}" 220 | f"{(now.year >> 8):02X}{(now.year & 0xFF):02X}" 221 | ) 222 | return (value, True) 223 | elif command == COMMAND_DEVICE_TYPE: 224 | return (self.device_type, False) 225 | else: 226 | LOGGER.warning(f"Unknown command: {command}") 227 | return (RETURN_INVALID + command, False) 228 | 229 | def _set_state_value(self, command: str, value: str): 230 | """Set state value for a command.""" 231 | if command == COMMAND_ON_OFF: 232 | if value == POWER_ON: 233 | self.is_on = True 234 | LOGGER.info("✓ Fan turned ON") 235 | elif value == POWER_OFF: 236 | self.is_on = False 237 | LOGGER.info("✓ Fan turned OFF") 238 | elif value == POWER_TOGGLE: 239 | self.is_on = not self.is_on 240 | LOGGER.info(f"✓ Fan toggled to {'ON' if self.is_on else 'OFF'}") 241 | elif command == COMMAND_SPEED: 242 | self.speed = value 243 | LOGGER.info(f"✓ Speed set to: {int(value, 16)}") 244 | elif command == COMMAND_MANUAL_SPEED: 245 | self.manual_speed = value 246 | percentage = (int(value, 16) / 255.0) * 100 247 | LOGGER.info(f"✓ Manual speed set to: {int(value, 16)} ({percentage:.1f}%)") 248 | elif command == COMMAND_DIRECTION: 249 | self.direction = value 250 | direction_names = {"00": "Forward", "01": "Reverse", "02": "Alternating"} 251 | LOGGER.info(f"✓ Direction set to: {direction_names.get(value, value)}") 252 | elif command == COMMAND_BOOST: 253 | self.boost = value != "00" 254 | LOGGER.info(f"✓ Boost {'enabled' if self.boost else 'disabled'}") 255 | elif command == COMMAND_MODE: 256 | self.mode = value 257 | mode_names = {"01": "Sleep", "02": "Party"} 258 | LOGGER.info(f"✓ Mode set to: {mode_names.get(value, value)}") 259 | elif command == COMMAND_RESET_FILTER_TIMER: 260 | self.filter_timer_minutes = 0 261 | LOGGER.info("✓ Filter timer reset") 262 | elif command == COMMAND_RESET_ALARMS: 263 | self.alarm = False 264 | LOGGER.info("✓ Alarms reset") 265 | else: 266 | LOGGER.warning(f"Unknown write command: {command} = {value}") 267 | 268 | def _handle_read(self, hexlist, data_start, data_end): 269 | response_data = "" 270 | i = data_start 271 | while i < data_end: 272 | cmd = hexlist[i] 273 | value, is_multibyte = self._get_state_value(cmd) 274 | if is_multibyte: 275 | response_data += RETURN_VALUE_SIZE + value 276 | else: 277 | response_data += cmd + value 278 | i += 1 279 | response = self._build_response_header() + response_data 280 | LOGGER.debug(f"Response before checksum: {response}") 281 | LOGGER.debug(f"Response data: {response_data}") 282 | response += self._checksum(response) 283 | LOGGER.debug(f"Response with checksum: {response}") 284 | response_bytes = bytes.fromhex(response) 285 | LOGGER.info(f"RESPONSE: {response}") 286 | LOGGER.info(f"Length: {len(response_bytes)} bytes") 287 | return response_bytes 288 | 289 | def _handle_write(self, hexlist, data_start, data_end): 290 | i = data_start 291 | while i < data_end: 292 | cmd = hexlist[i] 293 | if i + 1 < data_end: 294 | value = hexlist[i + 1] 295 | self._set_state_value(cmd, value) 296 | i += 2 297 | else: 298 | i += 1 299 | LOGGER.info("(No response for WRITE command)") 300 | return None 301 | 302 | def _handle_read_write(self, hexlist, data_start, data_end): 303 | response_data = "" 304 | i = data_start 305 | while i < data_end: 306 | cmd = hexlist[i] 307 | if i + 1 < data_end: 308 | value = hexlist[i + 1] 309 | self._set_state_value(cmd, value) 310 | new_value, is_multibyte = self._get_state_value(cmd) 311 | if is_multibyte: 312 | response_data += RETURN_VALUE_SIZE + new_value 313 | else: 314 | response_data += cmd + new_value 315 | i += 2 316 | else: 317 | i += 1 318 | response = self._build_response_header() + response_data 319 | response += self._checksum(response) 320 | response_bytes = bytes.fromhex(response) 321 | LOGGER.info(f"RESPONSE: {response}") 322 | LOGGER.info(f"Length: {len(response_bytes)} bytes") 323 | return response_bytes 324 | 325 | def _handle_inc(self, hexlist, data_start): 326 | cmd = hexlist[data_start] 327 | if cmd == COMMAND_SPEED: 328 | speed_int = int(self.speed, 16) 329 | if speed_int < 10: 330 | self.speed = f"{speed_int + 1:02X}" 331 | LOGGER.info(f"✓ Speed incremented to: {speed_int + 1}") 332 | elif cmd == COMMAND_MANUAL_SPEED: 333 | speed_int = int(self.manual_speed, 16) 334 | if speed_int < 255: 335 | self.manual_speed = f"{speed_int + 1:02X}" 336 | LOGGER.info(f"✓ Manual speed incremented to: {speed_int + 1}") 337 | value, is_multibyte = self._get_state_value(cmd) 338 | if is_multibyte: 339 | response_data = RETURN_VALUE_SIZE + value 340 | else: 341 | response_data = cmd + value 342 | response = self._build_response_header() + response_data 343 | response += self._checksum(response) 344 | response_bytes = bytes.fromhex(response) 345 | LOGGER.info(f"RESPONSE: {response}") 346 | return response_bytes 347 | 348 | def _handle_dec(self, hexlist, data_start): 349 | cmd = hexlist[data_start] 350 | if cmd == COMMAND_SPEED: 351 | speed_int = int(self.speed, 16) 352 | if speed_int > 1: 353 | self.speed = f"{speed_int - 1:02X}" 354 | LOGGER.info(f"✓ Speed decremented to: {speed_int - 1}") 355 | elif cmd == COMMAND_MANUAL_SPEED: 356 | speed_int = int(self.manual_speed, 16) 357 | if speed_int > 0: 358 | self.manual_speed = f"{speed_int - 1:02X}" 359 | LOGGER.info(f"✓ Manual speed decremented to: {speed_int - 1}") 360 | value, is_multibyte = self._get_state_value(cmd) 361 | if is_multibyte: 362 | response_data = RETURN_VALUE_SIZE + value 363 | else: 364 | response_data = cmd + value 365 | response = self._build_response_header() + response_data 366 | response += self._checksum(response) 367 | response_bytes = bytes.fromhex(response) 368 | LOGGER.info(f"RESPONSE: {response}") 369 | return response_bytes 370 | 371 | def process_packet(self, data: bytes) -> bytes | None: 372 | """Process received packet and return response.""" 373 | # Apply random delay if slow mode is enabled 374 | if self.slow_mode: 375 | delay = random.uniform(1.0, 7.0) 376 | LOGGER.info(f"Slow mode: delaying response by {delay:.2f} seconds...") 377 | time.sleep(delay) 378 | 379 | hex_str = data.hex().upper() 380 | hexlist = [hex_str[i : i + 2] for i in range(0, len(hex_str), 2)] 381 | 382 | LOGGER.info(f"\n{'=' * 60}") 383 | LOGGER.info(f"RECEIVED: {hex_str}") 384 | LOGGER.info(f"Length: {len(data)} bytes") 385 | 386 | # Verify checksum 387 | if not self._verify_checksum(hexlist): 388 | LOGGER.error("✗ Checksum verification failed!") 389 | return None 390 | LOGGER.info("✓ Checksum verified") 391 | 392 | # Verify authentication 393 | if not self._verify_auth(hexlist): 394 | LOGGER.error("✗ Authentication failed!") 395 | return None 396 | LOGGER.info("✓ Authentication successful") 397 | 398 | try: 399 | id_length = int(hexlist[3], 16) 400 | pwd_start = 4 + id_length 401 | pwd_length = int(hexlist[pwd_start], 16) 402 | func_pos = pwd_start + 1 + pwd_length 403 | 404 | func = hexlist[func_pos] 405 | data_start = func_pos + 1 406 | data_end = len(hexlist) - 2 # Exclude checksum 407 | 408 | LOGGER.info(f"Function: {func}") 409 | 410 | if func == FUNC_READ: 411 | LOGGER.info("Command: READ") 412 | return self._handle_read(hexlist, data_start, data_end) 413 | elif func == FUNC_WRITE: 414 | LOGGER.info("Command: WRITE") 415 | return self._handle_write(hexlist, data_start, data_end) 416 | elif func == FUNC_READ_WRITE: 417 | LOGGER.info("Command: READ_WRITE") 418 | return self._handle_read_write(hexlist, data_start, data_end) 419 | elif func == FUNC_INC: 420 | LOGGER.info("Command: INCREMENT") 421 | return self._handle_inc(hexlist, data_start) 422 | elif func == FUNC_DEC: 423 | LOGGER.info("Command: DECREMENT") 424 | return self._handle_dec(hexlist, data_start) 425 | else: 426 | LOGGER.error(f"Unknown function: {func}") 427 | return None 428 | 429 | except Exception as e: 430 | LOGGER.error(f"Error processing packet: {e}", exc_info=True) 431 | return None 432 | 433 | 434 | def main(): 435 | """Run the fake fan server.""" 436 | parser = argparse.ArgumentParser( 437 | description="Fake Siku Fan Controller Server for Testing", 438 | formatter_class=argparse.RawDescriptionHelpFormatter, 439 | epilog=""" 440 | Examples: 441 | %(prog)s 442 | %(prog)s --host 0.0.0.0 --port 4000 443 | %(prog)s --id "mydevice123456789012" --password "secret" 444 | """, 445 | ) 446 | parser.add_argument( 447 | "--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)" 448 | ) 449 | parser.add_argument( 450 | "--port", type=int, default=4000, help="Port to listen on (default: 4000)" 451 | ) 452 | parser.add_argument( 453 | "--id", 454 | dest="device_id", 455 | default="1234567890123456", 456 | help="Device ID (default: 1234567890123456)", 457 | ) 458 | parser.add_argument( 459 | "--password", default="1234", help="Device password (default: 1234)" 460 | ) 461 | parser.add_argument("--debug", action="store_true", help="Enable debug logging") 462 | parser.add_argument( 463 | "--slow", 464 | action="store_true", 465 | help="Enable slow mode (random 1-7 second delays in responses)", 466 | ) 467 | 468 | args = parser.parse_args() 469 | 470 | if args.debug: 471 | LOGGER.setLevel(logging.DEBUG) 472 | 473 | # Create fake fan controller 474 | fan = FakeFanController(args.device_id, args.password, args.slow) 475 | 476 | # Create UDP socket 477 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 478 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 479 | 480 | try: 481 | sock.bind((args.host, args.port)) 482 | LOGGER.info("\n%s", "=" * 60) 483 | LOGGER.info("Fake Siku Fan Server Started") 484 | LOGGER.info("Listening on %s:%s", args.host, args.port) 485 | LOGGER.info("%s\n", "=" * 60) 486 | LOGGER.info("Waiting for commands...\n") 487 | 488 | while True: 489 | data, addr = sock.recvfrom(4096) 490 | LOGGER.info("Connection from: %s:%s", addr[0], addr[1]) 491 | 492 | response = fan.process_packet(data) 493 | 494 | if response: 495 | sock.sendto(response, addr) 496 | 497 | LOGGER.info("%s\n", "=" * 60) 498 | 499 | except KeyboardInterrupt: 500 | LOGGER.info("\n\nShutting down server...") 501 | except Exception as e: 502 | LOGGER.error(f"Server error: {e}", exc_info=True) 503 | finally: 504 | sock.close() 505 | LOGGER.info("Server stopped.") 506 | 507 | 508 | if __name__ == "__main__": 509 | main() 510 | -------------------------------------------------------------------------------- /custom_components/siku/api_v2.py: -------------------------------------------------------------------------------- 1 | """Helper api function for sending commands to the fan controller.""" 2 | 3 | import time 4 | import logging 5 | import asyncio 6 | from homeassistant.util.percentage import percentage_to_ranged_value 7 | from .udp import AsyncUdpClient 8 | 9 | from .const import DIRECTION_ALTERNATING 10 | from .const import DIRECTIONS 11 | from .const import FAN_SPEEDS 12 | from .const import PRESET_MODE_AUTO 13 | from .const import PRESET_MODE_PARTY 14 | from .const import PRESET_MODE_SLEEP 15 | 16 | LOGGER = logging.getLogger(__name__) 17 | 18 | # forward = pull air out of the room 19 | # reverse = pull air into the room from outside 20 | # alternating = change directions (used for oscilating option in fan) 21 | 22 | PACKET_PREFIX = "FDFD" 23 | PACKET_PROTOCOL_TYPE = "02" 24 | PACKET_SIZE_ID = "10" 25 | 26 | FUNC_READ = "01" 27 | FUNC_WRITE = "02" 28 | FUNC_READ_WRITE = "03" 29 | FUNC_INC = "04" 30 | FUNC_DEC = "05" 31 | FUNC_RESULT = "06" # result func (FUNC = 0x01, 0x03, 0x04, 0x05). 32 | 33 | RETURN_CHANGE_FUNC = "FC" 34 | RETURN_INVALID = "FD" 35 | RETURN_VALUE_SIZE = "FE" 36 | RETURN_HIGH_BYTE = "FF" 37 | 38 | COMMAND_ON_OFF = "01" 39 | COMMAND_SPEED = "02" 40 | COMMAND_DIRECTION = "B7" 41 | COMMAND_DEVICE_TYPE = "B9" 42 | COMMAND_BOOST = "06" 43 | COMMAND_MODE = "07" 44 | COMMAND_TIMER_COUNTDOWN = "0B" 45 | COMMAND_CURRENT_HUMIDITY = "25" 46 | COMMAND_MANUAL_SPEED = "44" 47 | COMMAND_FAN1RPM = "4A" 48 | # Byte 1: Minutes (0...59) 49 | # Byte 2: Hours (0...23) 50 | # Byte 3: Days (0...181) 51 | COMMAND_FILTER_TIMER = "64" 52 | COMMAND_RESET_FILTER_TIMER = "65" 53 | COMMAND_SEARCH = "7C" 54 | COMMAND_RUN_HOURS = "7E" 55 | COMMAND_RESET_ALARMS = "80" 56 | COMMAND_READ_ALARM = "83" 57 | # Byte 1: Firmware-Version (major) 58 | # Byte 2: Firmware-Version (minor) 59 | # Byte 3: Day 60 | # Byte 4: Month 61 | # Byte 5 and 6: Year 62 | COMMAND_READ_FIRMWARE_VERSION = "86" 63 | COMMAND_FILTER_ALARM = "88" 64 | COMMAND_FAN_TYPE = "B9" 65 | 66 | COMMAND_FUNCTION_R = "01" 67 | COMMAND_FUNCTION_W = "02" 68 | COMMAND_FUNCTION_RW = "03" 69 | COMMAND_FUNCTION_INC = "04" 70 | COMMAND_FUNCTION_DEC = "05" 71 | 72 | POWER_OFF = "00" 73 | POWER_ON = "01" 74 | POWER_TOGGLE = "02" 75 | 76 | MODE_OFF = "01" 77 | MODE_SLEEP = "01" 78 | MODE_PARTY = "02" 79 | MODES = { 80 | MODE_OFF: PRESET_MODE_AUTO, 81 | MODE_SLEEP: PRESET_MODE_SLEEP, 82 | MODE_PARTY: PRESET_MODE_PARTY, 83 | } 84 | 85 | EMPTY_VALUE = "00" 86 | 87 | SPEED_MANUAL_MIN: int = 0 88 | SPEED_MANUAL_MAX: int = 255 89 | 90 | 91 | class SikuV2Api: 92 | """Handle requests to the fan controller.""" 93 | 94 | def __init__(self, host: str, port: int, idnum: str, password: str) -> None: 95 | """Initialize.""" 96 | self.host = host 97 | self.port = port 98 | self.idnum = idnum 99 | self.password = password 100 | self._udp = AsyncUdpClient(self.host, self.port) 101 | self._lock = asyncio.Lock() 102 | 103 | async def status(self) -> dict: 104 | """Get status from fan controller.""" 105 | commands = [ 106 | COMMAND_DEVICE_TYPE, 107 | COMMAND_ON_OFF, 108 | COMMAND_SPEED, 109 | COMMAND_MANUAL_SPEED, 110 | COMMAND_DIRECTION, 111 | COMMAND_BOOST, 112 | COMMAND_MODE, 113 | COMMAND_TIMER_COUNTDOWN, 114 | COMMAND_CURRENT_HUMIDITY, 115 | COMMAND_FAN1RPM, 116 | COMMAND_FILTER_TIMER, 117 | COMMAND_READ_ALARM, 118 | COMMAND_READ_FIRMWARE_VERSION, 119 | ] 120 | cmd = "".join(commands).upper() 121 | hexlist = await self._send_command(FUNC_READ, cmd) 122 | data = await self._parse_response(hexlist) 123 | return await self._translate_response(data) 124 | 125 | async def power_on(self) -> dict: 126 | """Power on fan.""" 127 | cmd = f"{COMMAND_ON_OFF}{POWER_ON}".upper() 128 | await self._send_command(FUNC_READ_WRITE, cmd) 129 | return await self.status() 130 | 131 | async def power_off(self) -> dict: 132 | """Power off fan.""" 133 | cmd = f"{COMMAND_ON_OFF}{POWER_OFF}".upper() 134 | await self._send_command(FUNC_READ_WRITE, cmd) 135 | return await self.status() 136 | 137 | async def speed(self, speed: str) -> dict: 138 | """Set fan speed.""" 139 | if speed not in FAN_SPEEDS: 140 | raise ValueError(f"Invalid fan speed: {speed}") 141 | cmd = f"{COMMAND_SPEED}{speed}".upper() 142 | await self._send_command(FUNC_READ_WRITE, cmd) 143 | return await self.status() 144 | 145 | async def speed_manual(self, percentage: int) -> dict: 146 | """Set manual fan speed.""" 147 | low_high_range = (float(SPEED_MANUAL_MIN), float(SPEED_MANUAL_MAX)) 148 | speed: int = int( 149 | round( 150 | percentage_to_ranged_value( 151 | low_high_range=low_high_range, percentage=float(percentage) 152 | ) 153 | ) 154 | ) 155 | cmd = f"{COMMAND_SPEED}FF{COMMAND_MANUAL_SPEED}{speed:02X}".upper() 156 | await self._send_command(FUNC_READ_WRITE, cmd) 157 | return await self.status() 158 | 159 | async def direction(self, direction: str) -> dict: 160 | """Set fan direction.""" 161 | # if direction is in DIRECTIONS values translate it to the key value 162 | if direction in DIRECTIONS.values(): 163 | direction = list(DIRECTIONS.keys())[ 164 | list(DIRECTIONS.values()).index(direction) 165 | ] 166 | if direction not in DIRECTIONS: 167 | raise ValueError(f"Invalid fan direction: {direction}") 168 | cmd = f"{COMMAND_DIRECTION}{direction}".upper() 169 | await self._send_command(FUNC_READ_WRITE, cmd) 170 | return await self.status() 171 | 172 | async def sleep(self) -> dict: 173 | """Set fan to sleep mode.""" 174 | cmd = f"{COMMAND_ON_OFF}{POWER_ON}{COMMAND_MODE}{MODE_SLEEP}".upper() 175 | await self._send_command(FUNC_READ_WRITE, cmd) 176 | return await self.status() 177 | 178 | async def party(self) -> dict: 179 | """Set fan to party mode.""" 180 | cmd = f"{COMMAND_ON_OFF}{POWER_ON}{COMMAND_MODE}{MODE_PARTY}".upper() 181 | await self._send_command(FUNC_READ_WRITE, cmd) 182 | return await self.status() 183 | 184 | async def reset_filter_alarm(self) -> dict: 185 | """Reset filter alarm.""" 186 | cmd = f"{COMMAND_RESET_ALARMS}{EMPTY_VALUE}{COMMAND_RESET_FILTER_TIMER}{EMPTY_VALUE}".upper() 187 | await self._send_command(FUNC_WRITE, cmd) 188 | return await self.status() 189 | 190 | def _checksum(self, data: str) -> str: 191 | """Calculate checksum for packet and return it as high order byte hex string.""" 192 | hexlist = self._hexlist(data) 193 | 194 | checksum = 0 195 | for hexstr in hexlist[2:]: 196 | checksum += int(hexstr, 16) 197 | checksum_str = f"{checksum:04X}" 198 | return f"{checksum_str[2:4]:02}{checksum_str[0:2]:02}" 199 | 200 | def _verify_checksum(self, hexlist: list[str]) -> bool: 201 | """Verify checksum of packet.""" 202 | checksum = self._checksum("".join(hexlist[0:-2])) 203 | LOGGER.debug("checksum: %s", checksum) 204 | LOGGER.debug("verify if %s == %s", checksum, hexlist[-2] + hexlist[-1]) 205 | return checksum == hexlist[-2] + hexlist[-1] 206 | 207 | def _hexlist(self, hexstr: str) -> list[str]: 208 | """Convert hex string to list of hex strings.""" 209 | return [hexstr[i : i + 2] for i in range(0, len(hexstr), 2)] 210 | 211 | def _login_packet(self) -> str: 212 | """Build initial login part of packet.""" 213 | id_hex = self.idnum.encode("utf-8").hex() 214 | password_size = f"{len(self.password):02x}" 215 | password_hex = self.password.encode("utf-8").hex() 216 | packet_str = ( 217 | PACKET_PREFIX 218 | + PACKET_PROTOCOL_TYPE 219 | + PACKET_SIZE_ID 220 | + id_hex 221 | + password_size 222 | + str(password_hex) 223 | ).upper() 224 | return packet_str 225 | 226 | def _build_packet(self, func: str, data: str) -> str: 227 | """Build packet for sending to fan controller.""" 228 | packet_str = (self._login_packet() + func + data).upper() 229 | LOGGER.debug("packet string: %s", packet_str) 230 | packet_str += self._checksum(packet_str) 231 | LOGGER.debug("packet string: %s", packet_str) 232 | return packet_str 233 | 234 | async def _send_command(self, func: str, data: str) -> list[str]: 235 | """Send command to fan controller using asyncio UDP transport.""" 236 | packet_str = self._build_packet(func, data) 237 | packet_data = bytes.fromhex(packet_str) 238 | 239 | # Map function codes to readable names for logging 240 | func_names = { 241 | FUNC_READ: "READ", 242 | FUNC_WRITE: "WRITE", 243 | FUNC_READ_WRITE: "READ_WRITE", 244 | FUNC_INC: "INCREMENT", 245 | FUNC_DEC: "DECREMENT", 246 | } 247 | func_name = func_names.get(func, f"UNKNOWN({func})") 248 | 249 | for attempt in range(3): 250 | start_time = time.time() 251 | try: 252 | if func == FUNC_WRITE: 253 | LOGGER.debug("write command, no response expected") 254 | async with self._lock: 255 | await self._udp.send_only(packet_data) 256 | elapsed = time.time() - start_time 257 | LOGGER.debug( 258 | "[%s:%d] WRITE command completed in %.3f seconds", 259 | self.host, 260 | self.port, 261 | elapsed, 262 | ) 263 | return [] 264 | 265 | LOGGER.debug( 266 | "[%s:%d] Sending %s request (attempt %d/3)", 267 | self.host, 268 | self.port, 269 | func_name, 270 | attempt + 1, 271 | ) 272 | async with self._lock: 273 | result_data = await self._udp.request(packet_data) 274 | elapsed = time.time() - start_time 275 | LOGGER.debug( 276 | "[%s:%d] %s request completed in %.3f seconds", 277 | self.host, 278 | self.port, 279 | func_name, 280 | elapsed, 281 | ) 282 | result_str = result_data.hex().upper() 283 | LOGGER.debug("receive string: %s", result_str) 284 | 285 | result_hexlist = ["".join(x) for x in zip(*[iter(result_str)] * 2)] 286 | if not self._verify_checksum(result_hexlist): 287 | raise ValueError("Checksum error") 288 | LOGGER.debug("returning hexlist %s", result_hexlist) 289 | return result_hexlist 290 | except (asyncio.TimeoutError, TimeoutError) as ex: 291 | elapsed = time.time() - start_time 292 | LOGGER.warning( 293 | "[%s:%d] %s request timed out after %.3f seconds (attempt %d/3). " 294 | "Packet: %s, Error: %s", 295 | self.host, 296 | self.port, 297 | func_name, 298 | elapsed, 299 | attempt + 1, 300 | packet_str[:40] + "..." if len(packet_str) > 40 else packet_str, 301 | type(ex).__name__, 302 | ) 303 | if attempt == 2: 304 | raise TimeoutError( 305 | f"Failed to send {func_name} command to {self.host}:{self.port} " 306 | f"after 3 attempts (total time: {elapsed:.3f}s)" 307 | ) from ex 308 | raise LookupError(f"Failed to send command to {self.host}:{self.port}") 309 | 310 | async def _translate_response(self, data: dict) -> dict: 311 | """Translate response data to dict.""" 312 | LOGGER.debug("translate response: %s", data) 313 | try: 314 | is_on = bool(data[COMMAND_ON_OFF] == POWER_ON) 315 | except KeyError: 316 | is_on = False 317 | try: 318 | speed = f"{int(data[COMMAND_SPEED], 16):02}" 319 | except KeyError: 320 | speed = "255" 321 | try: 322 | manual_speed = f"{int(data[COMMAND_MANUAL_SPEED], 16):02}" 323 | except KeyError: 324 | manual_speed = "00" 325 | try: 326 | direction = DIRECTIONS[data[COMMAND_DIRECTION]] 327 | oscillating = bool(direction == DIRECTION_ALTERNATING) 328 | except KeyError: 329 | direction = None 330 | oscillating = True 331 | try: 332 | boost = bool(data[COMMAND_BOOST] != "00") 333 | except KeyError: 334 | boost = False 335 | try: 336 | mode = MODES[data[COMMAND_MODE]] 337 | except KeyError: 338 | mode = PRESET_MODE_AUTO 339 | try: 340 | humidity = int(data[COMMAND_CURRENT_HUMIDITY], 16) 341 | except KeyError: 342 | humidity = None 343 | try: 344 | rpm = int(data[COMMAND_FAN1RPM], 16) 345 | except KeyError: 346 | rpm = 0 347 | try: 348 | # Byte 1: Minutes (0...59) 349 | # Byte 2: Hours (0...23) 350 | # Byte 3: Days (0...181) 351 | days = int(data[COMMAND_FILTER_TIMER][0:2], 16) 352 | hours = int(data[COMMAND_FILTER_TIMER][2:4], 16) 353 | minutes = int(data[COMMAND_FILTER_TIMER][4:6], 16) 354 | filter_timer = int(minutes + hours * 60 + days * 24 * 60) 355 | except KeyError: 356 | filter_timer = 0 357 | try: 358 | alarm = bool(data[COMMAND_READ_ALARM] != "00") 359 | except KeyError: 360 | alarm = False 361 | try: 362 | # Byte 1: Firmware-Version (major) 363 | # Byte 2: Firmware-Version (minor) 364 | # Byte 3: Day 365 | # Byte 4: Month 366 | # Byte 5 and 6: Year 367 | firmware = f"{int(data[COMMAND_READ_FIRMWARE_VERSION][0], 16)}.{int(data[COMMAND_READ_FIRMWARE_VERSION][1], 16)}" 368 | except KeyError: 369 | firmware = None 370 | try: 371 | # Byte 1 – seconds (0…59) 372 | # Byte 2 – minutes (0…59) 373 | # Byte 3 – hours (0…23) 374 | hours = int(data[COMMAND_TIMER_COUNTDOWN][0:2], 16) 375 | minutes = int(data[COMMAND_TIMER_COUNTDOWN][2:4], 16) 376 | seconds = int(data[COMMAND_TIMER_COUNTDOWN][4:6], 16) 377 | timer_countdown = int(seconds + minutes * 60 + hours * 60 * 60) 378 | except KeyError: 379 | timer_countdown = 0 380 | return { 381 | "is_on": is_on, 382 | "speed": speed, 383 | "speed_list": FAN_SPEEDS, 384 | "manual_speed_selected": bool(speed == "255"), 385 | "manual_speed": int(manual_speed), 386 | "manual_speed_low_high_range": ( 387 | float(SPEED_MANUAL_MIN), 388 | float(SPEED_MANUAL_MAX), 389 | ), 390 | "oscillating": oscillating, 391 | "direction": direction, 392 | "boost": boost, 393 | "mode": mode, 394 | "humidity": humidity, 395 | "rpm": rpm, 396 | "firmware": firmware, 397 | "filter_timer_days": filter_timer, 398 | "timer_countdown": timer_countdown, 399 | "alarm": alarm, 400 | "version": "2", 401 | } 402 | 403 | async def _parse_response(self, hexlist: list[str]) -> dict: 404 | """Translate response from fan controller.""" 405 | LOGGER.debug("parse response: %s", hexlist) 406 | data = {} 407 | try: 408 | start = 0 409 | 410 | # prefix 411 | LOGGER.debug("start: %s", start) 412 | packet = "".join(hexlist[start:2]) 413 | LOGGER.debug("hexlist: %s", packet) 414 | if packet != PACKET_PREFIX: 415 | LOGGER.error( 416 | "Invalid packet prefix (%s) %s != %s : %s", 417 | start, 418 | packet, 419 | PACKET_PREFIX, 420 | hexlist, 421 | ) 422 | raise ValueError( 423 | f"Invalid packet prefix ({start}) {packet} != {PACKET_PREFIX}" 424 | ) 425 | start += 2 426 | 427 | # protocol type 428 | LOGGER.debug("start: %s", start) 429 | packet = "".join(hexlist[start]) 430 | LOGGER.debug("hexlist: %s", packet) 431 | if packet != PACKET_PROTOCOL_TYPE: 432 | LOGGER.error( 433 | "Invalid packet protocol type (%s) %s != %s : %s", 434 | start, 435 | packet, 436 | PACKET_PROTOCOL_TYPE, 437 | hexlist, 438 | ) 439 | raise ValueError( 440 | f"Invalid packet protocol type ({start}) {packet} != {PACKET_PROTOCOL_TYPE}" 441 | ) 442 | start += 1 443 | 444 | # id 445 | LOGGER.debug("start: %s", start) 446 | packet = "".join(hexlist[start]) 447 | LOGGER.debug("hexlist: %s", packet) 448 | start += 1 + int(packet, 16) 449 | 450 | # password 451 | LOGGER.debug("start: %s", start) 452 | packet = "".join(hexlist[start]) 453 | LOGGER.debug("hexlist: %s", packet) 454 | start += 1 + int(packet, 16) 455 | 456 | # function 457 | LOGGER.debug("start: %s", start) 458 | packet = "".join(hexlist[start]) 459 | LOGGER.debug("hexlist: %s", packet) 460 | if packet != FUNC_RESULT: 461 | LOGGER.error( 462 | "Invalid result function (%s) %s != %s : %s", 463 | start, 464 | packet, 465 | FUNC_RESULT, 466 | hexlist, 467 | ) 468 | raise ValueError( 469 | f"Invalid result function ({start}) {packet} != {FUNC_RESULT}" 470 | ) 471 | start += 1 472 | 473 | # data 474 | LOGGER.debug("loop data %s %s", start, len(hexlist) - 2) 475 | i = start 476 | while i < (len(hexlist) - 2): 477 | LOGGER.debug("parse data %s : %s", i, hexlist[i]) 478 | parameter = hexlist[i] 479 | value_size = 1 480 | cmd = "" 481 | value = "" 482 | if parameter == RETURN_CHANGE_FUNC: 483 | LOGGER.debug( 484 | "special function, change base function not implemented %s", 485 | parameter, 486 | ) 487 | raise NotImplementedError( 488 | f"special function, change base function not implemented {parameter}" 489 | ) 490 | if parameter == RETURN_HIGH_BYTE: 491 | LOGGER.debug( 492 | "special function, high byte not implemented %s", parameter 493 | ) 494 | raise NotImplementedError( 495 | f"special function, high byte not implemented {parameter}" 496 | ) 497 | if parameter == RETURN_INVALID: 498 | i += 1 499 | cmd = hexlist[i] 500 | LOGGER.debug("special function, invalid cmd:%s", cmd) 501 | elif parameter == RETURN_VALUE_SIZE: 502 | i += 1 503 | value_size = int(hexlist[i], 16) 504 | LOGGER.debug("special function, value size %s", value_size) 505 | i += 1 506 | cmd = hexlist[i] 507 | value = "".join(hexlist[i + 1 : i + 1 + value_size]) 508 | # reverse byte order 509 | value = "".join( 510 | [value[idx : idx + 2] for idx in range(0, len(value), 2)][::-1] 511 | ) 512 | i += value_size 513 | else: 514 | cmd = parameter 515 | i += 1 516 | value = hexlist[i] 517 | LOGGER.debug("normal function, cmd:%s value:%s", cmd, value) 518 | 519 | data.update({cmd: value}) 520 | LOGGER.debug( 521 | "return data cmd:%s value:%s", 522 | cmd, 523 | value, 524 | ) 525 | i += 1 526 | except KeyError as ex: 527 | raise ValueError( 528 | f"Error translating response from fan controller: {str(ex)}" 529 | ) from ex 530 | return data 531 | -------------------------------------------------------------------------------- /custom_components/siku/api_v1.py: -------------------------------------------------------------------------------- 1 | """Helper api function for sending commands to the fan controller.""" 2 | 3 | from enum import IntEnum 4 | import time 5 | import logging 6 | import asyncio 7 | from types import NoneType 8 | from typing import Literal 9 | from .udp import AsyncUdpClient 10 | from homeassistant.util.percentage import percentage_to_ranged_value 11 | 12 | from .const import DIRECTIONS 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | COMMAND_PACKET_PREFIX = bytes.fromhex("6D6F62696C65") 17 | COMMAND_PACKET_POSTFIX = bytes.fromhex("0D0A") 18 | FEEDBACK_PACKET_PREFIX = bytes.fromhex("6D6173746572") 19 | 20 | 21 | class SpeedSelection(IntEnum): 22 | """Speed selection for preset mode.""" 23 | 24 | LOW = 1 25 | MEDIUM = 2 26 | HIGH = 3 27 | MANUAL = 4 28 | 29 | 30 | SPEED_MANUAL_LOW: int = 0 31 | SPEED_MANUAL_MIN: int = 22 32 | SPEED_MANUAL_MAX: int = 255 33 | 34 | 35 | class SpeedManual: 36 | """Manual speed selection.""" 37 | 38 | def __init__(self, value: int): 39 | """Initialize checks for allowed values.""" 40 | if SPEED_MANUAL_MIN < value > SPEED_MANUAL_MAX: 41 | raise ValueError("Value must be between 22 and 255") 42 | self.value = value 43 | 44 | def __int__(self): 45 | """Return the value.""" 46 | return self.value 47 | 48 | def to_bytes( 49 | self, length: int, byteorder: Literal["big", "little"] = "big" 50 | ) -> bytes: 51 | """Convert to bytes.""" 52 | return self.value.to_bytes(length, byteorder=byteorder) 53 | 54 | 55 | class Direction(IntEnum): 56 | """Direction selection for fan.""" 57 | 58 | VENTILATION = 0 # push air out of the room 59 | HEAT_RECOVERY = 1 # alternate between pushing air out and pulling air in 60 | AIR_SUPPLY = 2 # pull air into the room 61 | 62 | 63 | class Timer(IntEnum): 64 | """Timer selection for fan.""" 65 | 66 | AUTO = 0 67 | NIGHT = 1 68 | PARTY = 2 69 | 70 | 71 | class HumiditySensorThreshold: 72 | """Humidity sensor threshold setting, [RH%].""" 73 | 74 | def __init__(self, value: int): 75 | """Initialize checks for allowed values.""" 76 | if 40 < value > 80: 77 | raise ValueError("Value must be between 40 and 80") 78 | self.value = value 79 | 80 | def __int__(self): 81 | """Return the value.""" 82 | return self.value 83 | 84 | 85 | class HumidityLevel: 86 | """Humidity sensor threshold setting, [RH%].""" 87 | 88 | def __init__(self, value: int): 89 | """Initialize checks for allowed values.""" 90 | if 39 < value > 90: 91 | raise ValueError("Value must be between 40 and 80") 92 | self.value = value 93 | 94 | def __int__(self): 95 | """Return the value.""" 96 | return self.value 97 | 98 | 99 | class TimerSeconds: 100 | """Timer selection for fan.""" 101 | 102 | def __init__(self, value: int): 103 | """Initialize checks for allowed values.""" 104 | if 0 < value > 86400: 105 | raise ValueError( 106 | f"Invalid value {value}. Value must be between 0 and 86400." 107 | ) 108 | self.value = value 109 | 110 | def __int__(self): 111 | """Return the value.""" 112 | return self.value 113 | 114 | 115 | class OffOn(IntEnum): 116 | """Device status (On/Off).""" 117 | 118 | OFF = 0 119 | ON = 1 120 | 121 | 122 | class SpeedSelected(IntEnum): 123 | """Selected device speed.""" 124 | 125 | LOW = 1 126 | MEDIUM = 2 127 | HIGH = 3 128 | MANUAL = 4 129 | 130 | 131 | class OperationMode(IntEnum): 132 | """Timer selection for fan.""" 133 | 134 | REGULAR = 0 135 | NIGHT = 1 136 | PARTY = 2 137 | 138 | 139 | class NoYes(IntEnum): 140 | """No/Yes response.""" 141 | 142 | NO = 0 143 | YES = 1 144 | 145 | 146 | class NoYesYes(IntEnum): 147 | """No/Yes response.""" 148 | 149 | NO = 0 150 | YES = 1 151 | YES2 = 2 152 | 153 | 154 | class ZeroTenVoltThreshold: 155 | """0 - 10 V sensor activation threshold, [%].""" 156 | 157 | def __init__(self, value: int): 158 | """Initialize checks for allowed values.""" 159 | if 5 < value > 100: 160 | raise ValueError("Value must be between 0 and 255") 161 | self.value = value 162 | 163 | def __int__(self): 164 | """Return the value.""" 165 | return self.value 166 | 167 | 168 | CONTROL = { 169 | "status": {"cmd": 1, "size": 1, "value": NoneType}, 170 | "activation": {"cmd": 2, "size": 1, "value": NoneType}, 171 | "power": {"cmd": 3, "size": 1, "value": NoneType}, 172 | "speed": {"cmd": 4, "size": 1, "value": SpeedSelection}, 173 | "manual_speed": {"cmd": 5, "size": 1, "value": SpeedManual}, 174 | "direction": {"cmd": 6, "size": 1, "value": Direction}, 175 | "timer": {"cmd": 9, "size": 1, "value": Timer}, 176 | "humidity_sensor_threshold": { 177 | "cmd": 11, 178 | "size": 1, 179 | "value": HumiditySensorThreshold, 180 | }, 181 | "night_mode_timer": {"cmd": 15, "size": 3, "value": TimerSeconds}, 182 | "party_mode_timer": {"cmd": 16, "size": 3, "value": TimerSeconds}, 183 | "deactivation_delay_timer": {"cmd": 17, "size": 3, "value": TimerSeconds}, 184 | "humidity_sensor": {"cmd": 21, "size": 1, "value": NoneType}, 185 | "reset_filter_alarm": {"cmd": 30, "size": 1, "value": NoneType}, 186 | } 187 | 188 | FEEDBACK = { 189 | "status": { 190 | "cmd": 3, 191 | "size": 1, 192 | "value": OffOn, 193 | "description": "Device status (On/Off)", 194 | }, 195 | "speed": { 196 | "cmd": 4, 197 | "size": 1, 198 | "value": SpeedSelected, 199 | "description": "Selected speed", 200 | }, 201 | "manual_speed": { 202 | "cmd": 5, 203 | "size": 1, 204 | "value": SpeedManual, 205 | "description": "Manual speed setting value ", 206 | }, 207 | "direction": { 208 | "cmd": 6, 209 | "size": 1, 210 | "value": Direction, 211 | "description": "Air flow direction", 212 | }, 213 | "humidity_level": { 214 | "cmd": 8, 215 | "size": 1, 216 | "value": HumidityLevel, 217 | "description": "Current humidity level, [RH%]", 218 | }, 219 | "operation_mode": { 220 | "cmd": 9, 221 | "size": 1, 222 | "value": OperationMode, 223 | "description": "Operation mode", 224 | }, 225 | "humidity_sensor_threshold": { 226 | "cmd": 11, 227 | "size": 1, 228 | "value": HumiditySensorThreshold, 229 | "description": "Humidity sensor activation threshold, [RH%] ", 230 | }, 231 | "alarm_status": { 232 | "cmd": 12, 233 | "size": 1, 234 | "value": NoYes, 235 | "description": "Alarm status (emergency stop)(status bar) ", 236 | }, 237 | "relay_sensor_status": { 238 | "cmd": 13, 239 | "size": 1, 240 | "value": NoYes, 241 | "description": "Relay sensor status (status bar)", 242 | }, 243 | "timer_countdown": { 244 | "cmd": 14, 245 | "size": 3, 246 | "value": TimerSeconds, 247 | "description": "Party mode / night mode timer countdown, [sec]", 248 | }, 249 | "night_mode_timer": { 250 | "cmd": 15, 251 | "size": 3, 252 | "value": TimerSeconds, 253 | "description": "Current night time mode timer setting, [sec]", 254 | }, 255 | "party_mode_timer": { 256 | "cmd": 16, 257 | "size": 3, 258 | "value": TimerSeconds, 259 | "description": "Current party mode timer setting, [sec]", 260 | }, 261 | "boost_mode_timer": { 262 | "cmd": 17, 263 | "size": 3, 264 | "value": TimerSeconds, 265 | "description": "Current deactivation delay timer setting (boost mode), [sec]", 266 | }, 267 | "filter_end_of_life": { 268 | "cmd": 18, 269 | "size": 1, 270 | "value": NoYes, 271 | "description": "Filter end of life message (status bar)", 272 | }, 273 | "humidity_sensor_status": { 274 | "cmd": 19, 275 | "size": 1, 276 | "value": NoYes, 277 | "description": "Humidity sensor status (status bar)", 278 | }, 279 | "boost_mode_after_sensor": { 280 | "cmd": 20, 281 | "size": 1, 282 | "value": NoYesYes, 283 | "description": "BOOST mode after any sensor response (status bar)", 284 | }, 285 | "humidity_sensor": { 286 | "cmd": 21, 287 | "size": 1, 288 | "value": OffOn, 289 | "description": "Humidity sensor", 290 | }, 291 | "relay_sensor": { 292 | "cmd": 22, 293 | "size": 1, 294 | "value": OffOn, 295 | "description": "Relay sensor", 296 | }, 297 | "zero_ten_volt_sensor": { 298 | "cmd": 23, 299 | "size": 1, 300 | "value": OffOn, 301 | "description": "0-10 V sensor", 302 | }, 303 | "zero_ten_volt_threshold": { 304 | "cmd": 25, 305 | "size": 1, 306 | "value": ZeroTenVoltThreshold, 307 | "description": "0-10 V sensor activation threshold, [%]", 308 | }, 309 | "zero_ten_volt_sensor_status": { 310 | "cmd": 26, 311 | "size": 1, 312 | "value": NoYes, 313 | "description": "0-10 V sensor status (status bar) ", 314 | }, 315 | "slave_search": { 316 | "cmd": 27, 317 | "size": 32, 318 | "value": int, 319 | "description": "Slave search", 320 | }, 321 | "slave_response": { 322 | "cmd": 28, 323 | "size": 4, 324 | "value": int, 325 | "description": "Response to «Slave device search» request", 326 | }, 327 | "cloud_status": { 328 | "cmd": 31, 329 | "size": 1, 330 | "value": NoYes, 331 | "description": "Cloud server control activation status", 332 | }, 333 | "zero_ten_volt_current": { 334 | "cmd": 37, 335 | "size": 1, 336 | "value": int, 337 | "description": "0-10 V sensor current status", 338 | }, 339 | } 340 | 341 | 342 | class SikuV1Api: 343 | """Handle requests to the fan controller.""" 344 | 345 | def __init__(self, host: str, port: int) -> None: 346 | """Initialize.""" 347 | self.host = host 348 | self.port = port 349 | self._udp = AsyncUdpClient(self.host, self.port) 350 | self._lock = asyncio.Lock() 351 | 352 | async def status(self) -> dict: 353 | """Get status from fan controller.""" 354 | data = await self._control_packet([("status", None)]) 355 | hexlist = await self._send_command(data) 356 | result = await self._translate_response(hexlist) 357 | return await self._format_response(result) 358 | 359 | async def power_on(self) -> dict: 360 | """Power on fan.""" 361 | data = await self._control_packet([("status", None)]) 362 | hexlist = await self._send_command(data) 363 | result = await self._translate_response(hexlist) 364 | if result["status"] == OffOn.OFF: 365 | data = await self._control_packet([("power", None)]) 366 | hexlist = await self._send_command(data) 367 | result = await self._translate_response(hexlist) 368 | LOGGER.info("Power ON fan : %s", result["operation_mode"]) 369 | return await self._format_response(result) 370 | 371 | async def power_off(self) -> dict: 372 | """Power off fan.""" 373 | data = await self._control_packet([("status", None)]) 374 | hexlist = await self._send_command(data) 375 | result = await self._translate_response(hexlist) 376 | if result["status"] == OffOn.ON: 377 | data = await self._control_packet([("power", None)]) 378 | hexlist = await self._send_command(data) 379 | result = await self._translate_response(hexlist) 380 | LOGGER.info("Power OFF fan : %s", result["operation_mode"]) 381 | return await self._format_response(result) 382 | 383 | async def speed(self, speed: str | int) -> dict: 384 | """Set fan speed.""" 385 | _speed: SpeedSelection = SpeedSelection(int(speed)) 386 | data = await self._control_packet([("speed", _speed)]) 387 | hexlist = await self._send_command(data) 388 | result = await self._translate_response(hexlist) 389 | return await self._format_response(result) 390 | 391 | async def speed_manual(self, percentage: int) -> dict: 392 | """Set manual fan speed.""" 393 | if percentage < 9: 394 | percentage = 9 395 | if percentage > 100: 396 | percentage = 100 397 | low_high_range = (float(SPEED_MANUAL_LOW), float(SPEED_MANUAL_MAX)) 398 | _speed: SpeedManual = SpeedManual( 399 | int( 400 | percentage_to_ranged_value( 401 | low_high_range=low_high_range, percentage=float(percentage) 402 | ) 403 | ) 404 | ) 405 | data = await self._control_packet( 406 | [ 407 | ("speed", SpeedSelection.MANUAL), 408 | ("manual_speed", _speed), 409 | ] 410 | ) 411 | hexlist = await self._send_command(data) 412 | result = await self._translate_response(hexlist) 413 | return await self._format_response(result) 414 | 415 | async def direction(self, direction: str | int) -> dict: 416 | """Set fan direction.""" 417 | # if direction is in DIRECTIONS values translate it to the key value 418 | # NOTE: cleanup desired 419 | if direction in DIRECTIONS.values(): 420 | direction = list(DIRECTIONS.keys())[ 421 | list(DIRECTIONS.values()).index(str(direction)) 422 | ] 423 | if direction not in DIRECTIONS: 424 | raise ValueError(f"Invalid fan direction: {direction}") 425 | direction = Direction(int(direction)) 426 | data = await self._control_packet([("direction", direction)]) 427 | hexlist = await self._send_command(data) 428 | result = await self._translate_response(hexlist) 429 | LOGGER.info("Set direction to %s : %s", direction, result["direction"]) 430 | return await self._format_response(result) 431 | 432 | async def sleep(self) -> dict: 433 | """Set fan to sleep mode.""" 434 | await self.power_on() 435 | data = await self._control_packet([("timer", Timer.NIGHT)]) 436 | hexlist = await self._send_command(data) 437 | result = await self._translate_response(hexlist) 438 | LOGGER.info("Set sleep mode : %s", result["operation_mode"]) 439 | return await self._format_response(result) 440 | 441 | async def party(self) -> dict: 442 | """Set fan to party mode.""" 443 | await self.power_on() 444 | data = await self._control_packet([("timer", Timer.PARTY)]) 445 | hexlist = await self._send_command(data) 446 | result = await self._translate_response(hexlist) 447 | LOGGER.info( 448 | "Set party mode : %s timer:%s", 449 | result["operation_mode"], 450 | result["timer_countdown"], 451 | ) 452 | result["status"] = OffOn.ON 453 | result["speed"] = SpeedSelection.HIGH 454 | result["direction"] = Direction.VENTILATION 455 | LOGGER.info( 456 | "Overwrite party mode values : status:%s speed:%s direction:%s", 457 | result["status"], 458 | result["speed"], 459 | result["direction"], 460 | ) 461 | return await self._format_response(result) 462 | 463 | async def reset_filter_alarm(self) -> dict: 464 | """Reset filter alarm.""" 465 | await self.power_on() 466 | data = await self._control_packet([("reset_filter_alarm", None)]) 467 | hexlist = await self._send_command(data) 468 | result = await self._translate_response(hexlist) 469 | LOGGER.info("Reset filter alarm : %s", result["filter_end_of_life"]) 470 | return await self._format_response(result) 471 | 472 | async def _control_packet(self, commands: list[tuple]) -> bytes: 473 | """Generate packet data for fan control.""" 474 | if not isinstance(commands, list): 475 | raise TypeError("Commands must be a list of tuples") 476 | if not all(isinstance(command, tuple) for command in commands): 477 | raise TypeError("Commands must be a list of tuples") 478 | packet_data_list = [] 479 | for command, value in commands: 480 | if command not in CONTROL: 481 | raise ValueError(f"Invalid command: {command}") 482 | if not isinstance(value, CONTROL[command]["value"]): 483 | raise TypeError( 484 | f"Invalid value {value} for command {command}: got {type(value)} but must be of type {CONTROL[command]['value']}" 485 | ) 486 | 487 | # packet_command = bytes.fromhex(command) 488 | packet_command = CONTROL[command]["cmd"].to_bytes(1, byteorder="big") 489 | packet_size = CONTROL[command]["size"] 490 | if isinstance(value, NoneType): 491 | value = 0 492 | # LOGGER.debug("value: %s (%s)", value, type(value)) 493 | packet_value = value.to_bytes(packet_size, byteorder="big") 494 | # LOGGER.debug("packet_value: %s", packet_value) 495 | # LOGGER.debug("packet_command: %s", packet_command) 496 | packet_data_list.append(packet_command + packet_value) 497 | 498 | # LOGGER.debug("packet_data_list: %s", packet_data_list) 499 | packet_commands = b"".join(packet_data_list) 500 | # LOGGER.debug("packet_commands: %s", packet_commands) 501 | # LOGGER.debug("packet_commands: %s", packet_commands.hex()) 502 | return packet_commands 503 | 504 | async def _send_command(self, data: bytes) -> list[str]: 505 | """Send command to fan controller using asyncio UDP transport.""" 506 | packet_data = COMMAND_PACKET_PREFIX + data + COMMAND_PACKET_POSTFIX 507 | packet_hex = packet_data.hex().upper() 508 | 509 | for attempt in range(3): 510 | start_time = time.time() 511 | try: 512 | LOGGER.debug( 513 | "[%s:%d] Sending request (attempt %d/3)", 514 | self.host, 515 | self.port, 516 | attempt + 1, 517 | ) 518 | async with self._lock: 519 | data_bytes = await self._udp.request(packet_data) 520 | elapsed = time.time() - start_time 521 | LOGGER.debug( 522 | "[%s:%d] Request completed in %.3f seconds", 523 | self.host, 524 | self.port, 525 | elapsed, 526 | ) 527 | # Match feedback packet prefix and cut from the data 528 | if data_bytes.startswith(FEEDBACK_PACKET_PREFIX): 529 | data_bytes = data_bytes[len(FEEDBACK_PACKET_PREFIX) :] 530 | hexstring = data_bytes.hex() 531 | hexlist = ["".join(x) for x in zip(*[iter(hexstring)] * 2)] 532 | LOGGER.debug("returning hexlist %s", hexlist) 533 | return hexlist 534 | except (asyncio.TimeoutError, TimeoutError) as ex: 535 | elapsed = time.time() - start_time 536 | LOGGER.warning( 537 | "[%s:%d] Request timed out after %.3f seconds (attempt %d/3). " 538 | "Packet: %s, Error: %s", 539 | self.host, 540 | self.port, 541 | elapsed, 542 | attempt + 1, 543 | packet_hex[:40] + "..." if len(packet_hex) > 40 else packet_hex, 544 | type(ex).__name__, 545 | ) 546 | if attempt == 2: 547 | raise TimeoutError( 548 | f"Failed to send command to {self.host}:{self.port} " 549 | f"after 3 attempts (total time: {elapsed:.3f}s)" 550 | ) from ex 551 | raise LookupError(f"Failed to send command to {self.host}:{self.port}") 552 | 553 | async def _translate_response(self, hexlist: list[str]) -> dict: 554 | """Translate response from fan controller.""" 555 | data = {} 556 | # traverse hexlist response and match feedback params 557 | i = 0 558 | while i < len(hexlist): 559 | cmd = hexlist[i] 560 | cmd = int(hexlist[i], 16) 561 | # loop all of the feedback params to find a match 562 | for key, item in FEEDBACK.items(): 563 | if cmd == item["cmd"]: 564 | size = item["size"] 565 | value_type = item["value"] 566 | value_raw = hexlist[i + 1 : i + 1 + size] 567 | # LOGGER.debug("value_raw:%s", value_raw) 568 | if value_type is not NoneType: 569 | value = int("".join(value_raw), 16) 570 | # LOGGER.debug("value:%s", value) 571 | value = value_type(value) 572 | # LOGGER.debug("value:%s", value) 573 | else: 574 | value = None 575 | data[key] = value 576 | LOGGER.debug( 577 | "index:%s/%s key:%s cmd:%s size:%s value_raw:%s value:%s", 578 | i, 579 | len(hexlist), 580 | key, 581 | cmd, 582 | size, 583 | value_raw, 584 | value, 585 | ) 586 | i += 1 + size 587 | break 588 | else: 589 | LOGGER.debug("index:%s/%s No match for cmd:%s", i, len(hexlist), cmd) 590 | i += 2 591 | return data 592 | 593 | async def _format_response(self, data: dict) -> dict: 594 | """Format response for entities.""" 595 | return { 596 | "is_on": bool(data["status"] == OffOn.ON), 597 | "speed": int(data["speed"]), 598 | "speed_list": [ 599 | int(s) for s in SpeedSelection if s != SpeedSelection.MANUAL 600 | ], 601 | "manual_speed_selected": bool(data["speed"] == SpeedSelected.MANUAL), 602 | "manual_speed": int(data["manual_speed"]), 603 | "manual_speed_low_high_range": ( 604 | float(SPEED_MANUAL_LOW), 605 | float(SPEED_MANUAL_MAX), 606 | ), 607 | "oscillating": bool(data["direction"] == Direction.HEAT_RECOVERY), 608 | "direction": ( 609 | data["direction"] 610 | if data["direction"] == Direction.HEAT_RECOVERY 611 | else None 612 | ), 613 | "mode": data["operation_mode"], 614 | "humidity": int(data["humidity_level"]), 615 | "alarm": bool(data["filter_end_of_life"] == NoYes.YES), 616 | "timer_countdown": int(data["timer_countdown"]), 617 | "boost": bool( 618 | data["boost_mode_after_sensor"] == NoYesYes.YES 619 | or data["boost_mode_after_sensor"] == NoYesYes.YES2 620 | ), 621 | "boost_mode_timer": int(data["boost_mode_timer"]) 622 | if "boost_mode_timer" in data 623 | else None, 624 | "night_mode_timer": int(data["night_mode_timer"]) 625 | if "night_mode_timer" in data 626 | else None, 627 | "party_mode_timer": int(data["party_mode_timer"]) 628 | if "party_mode_timer" in data 629 | else None, 630 | "version": "1", 631 | } 632 | --------------------------------------------------------------------------------