├── src ├── __init__.py └── dirigera │ ├── hub │ ├── __init__.py │ ├── utils.py │ ├── abstract_smart_home_hub.py │ ├── auth.py │ └── hub.py │ ├── devices │ ├── __init__.py │ ├── base_ikea_model.py │ ├── water_sensor.py │ ├── open_close_sensor.py │ ├── occupancy_sensor.py │ ├── controller.py │ ├── motion_sensor.py │ ├── light_sensor.py │ ├── environment_sensor.py │ ├── device.py │ ├── blinds.py │ ├── outlet.py │ ├── air_purifier.py │ ├── scene.py │ └── light.py │ └── __init__.py ├── tests ├── __init__.py ├── test_utils.py ├── test_occupancy_sensor.py ├── test_light_sensor.py ├── test_environment_sensor.py ├── test_water_sensor.py ├── test_open_close_sensor.py ├── test_blinds.py ├── test_scenes.py ├── test_outlet.py ├── test_motion_sensor.py ├── test_air_purifier.py ├── test_controller.py └── test_light.py ├── run-build.sh ├── setup.py ├── dev-requirements.txt ├── run-pylint.sh ├── mypy.ini ├── requirements.txt ├── .gitignore ├── .pylintrc ├── run-python-versions-test.sh ├── run-mypy.sh ├── run-test.sh ├── setup.sh ├── .vscode └── settings.json ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ ├── publish-to-pypi.yml │ └── tests.yml └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /run-build.sh: -------------------------------------------------------------------------------- 1 | python3 -m build -------------------------------------------------------------------------------- /src/dirigera/hub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dirigera/devices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dirigera/__init__.py: -------------------------------------------------------------------------------- 1 | from .hub.hub import Hub 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup # type: ignore 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pylint==3.3.0 2 | pytest==7.3.1 3 | mypy==1.6.1 4 | types-requests==2.* -------------------------------------------------------------------------------- /run-pylint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source venv/bin/activate 4 | python3 -m pylint $(git ls-files '*.py') -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Global options: 2 | 3 | [mypy] 4 | exclude = build 5 | disallow_untyped_defs = True 6 | follow_imports = silent 7 | plugins = pydantic.mypy 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.* 2 | websocket-client==1.5.1 3 | 4 | pydantic==2.4.2; python_version < "3.13" 5 | pydantic>=2.8; python_version >= "3.13" 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | /.idea/ 3 | /pipeforce-service-template-python.iml 4 | **/.pytest_cache/ 5 | /venv/ 6 | .env 7 | __pycache__/ 8 | result.xml 9 | *.xml 10 | .coverage 11 | *.egg-info 12 | /dist 13 | /build -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | max-line-length = 120 3 | max-locals = 25 4 | disable = 5 | C0114, # missing-module-docstring 6 | C0115, # missing-class-docstring 7 | C0116, # missing-function-docstring 8 | R0902, # too-many-instance-attributes 9 | R0913, # too-many-arguments 10 | R0801, # duplicate-code 11 | R0917, # too-many-positional-arguments -------------------------------------------------------------------------------- /src/dirigera/devices/base_ikea_model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | def to_camel(string: str) -> str: 5 | string_split = string.split("_") 6 | return string_split[0] + "".join(word.capitalize() for word in string_split[1:]) 7 | 8 | 9 | class BaseIkeaModel(BaseModel, arbitrary_types_allowed=True, alias_generator=to_camel): 10 | pass 11 | -------------------------------------------------------------------------------- /run-python-versions-test.sh: -------------------------------------------------------------------------------- 1 | declare -a arr=("3.7" "3.8" "3.9" "3.10" "3.11" "3.12") 2 | set -e 3 | set -o pipefail 4 | for i in "${arr[@]}" 5 | do 6 | echo "$i" 7 | docker run -v ~/src/dirigera:/dirigera python:"$i"-slim /bin/bash -c " 8 | cd /dirigera 9 | pip install -r requirements.txt > /dev/null 10 | pip install -r dev-requirements.txt > /dev/null 11 | bash run-test.sh" 12 | 13 | done -------------------------------------------------------------------------------- /run-mypy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -a 3 | source .env 4 | 5 | if [ "$(uname)" == "Darwin" ]; then 6 | source venv/bin/activate 7 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 8 | source venv/bin/activate 9 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW32_NT" ]; then 10 | source venv/Scripts/activate 11 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW64_NT" ]; then 12 | source venv/Scripts/activate 13 | fi 14 | 15 | mypy . 16 | -------------------------------------------------------------------------------- /run-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -a 3 | source .env 4 | 5 | if [ "$(uname)" == "Darwin" ]; then 6 | source venv/bin/activate 7 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 8 | source venv/bin/activate 9 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW32_NT" ]; then 10 | source venv/Scripts/activate 11 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW64_NT" ]; then 12 | source venv/Scripts/activate 13 | fi 14 | 15 | pytest . 16 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python3 -m venv venv 4 | 5 | if [ "$(uname)" == "Darwin" ]; then 6 | source venv/bin/activate 7 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 8 | source venv/bin/activate 9 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW32_NT" ]; then 10 | source venv/Scripts/activate 11 | elif [ "$(expr substr $(uname -s) 1 10)" == "MINGW64_NT" ]; then 12 | source venv/Scripts/activate 13 | fi 14 | 15 | pip install -r requirements.txt 16 | pip install -r dev-requirements.txt 17 | -------------------------------------------------------------------------------- /src/dirigera/hub/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import re 3 | from typing import Any, Dict, List, Union 4 | 5 | 6 | def camelize_dict( 7 | data: Union[Dict[str, Any], List[Any]] 8 | ) -> Union[Dict[str, Any], List[Any]]: 9 | camelize = functools.partial(re.sub, r"_([a-z])", lambda m: m.group(1).upper()) 10 | 11 | if isinstance(data, list): 12 | return [camelize_dict(i) if isinstance(i, (dict, list)) else i for i in data] 13 | return { 14 | camelize(a): camelize_dict(b) if isinstance(b, (dict, list)) else b 15 | for a, b in data.items() 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.pylintArgs": [ 4 | "--rcfile=${workspaceFolder}/.pylintrc" 5 | ], 6 | "python.testing.unittestArgs": [ 7 | "-v", 8 | "-s", 9 | ".", 10 | "-p", 11 | "test*.py" 12 | ], 13 | "python.testing.pytestEnabled": true, 14 | "python.testing.unittestEnabled": false, 15 | "python.formatting.provider": "black", 16 | "python.linting.mypyEnabled": true, 17 | "python.linting.mypyArgs": ["--config-file=${workspaceFolder}/mypy.ini"], 18 | "python.envFile": "${workspaceFolder}/.env", 19 | "editor.formatOnSave": true, 20 | "python.testing.pytestArgs": [ 21 | "src" 22 | ] 23 | } -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from src.dirigera.hub.utils import camelize_dict 2 | 3 | 4 | def test_camelize_dict() -> None: 5 | data = { 6 | "key_a": "data_b", 7 | "key_b": { 8 | "key_b_a": "data_b_a", 9 | "key_b_b": {"key_b_b_a": "data_b_a", "key_b_b_b": ["a_b_c"]}, 10 | }, 11 | } 12 | result = camelize_dict(data) 13 | assert "keyA" in result 14 | assert isinstance(result, dict) 15 | assert result["keyA"] == data["key_a"] 16 | assert "keyB" in result 17 | assert "keyBA" in result["keyB"] 18 | assert isinstance(data["key_b"], dict) 19 | assert result["keyB"]["keyBA"] == data["key_b"]["key_b_a"] 20 | assert "keyBBA" in result["keyB"]["keyBB"] 21 | assert result["keyB"]["keyBB"]["keyBBA"] == data["key_b"]["key_b_b"]["key_b_b_a"] 22 | assert "keyBBB" in result["keyB"]["keyBB"] 23 | assert result["keyB"]["keyBB"]["keyBBB"] == data["key_b"]["key_b_b"]["key_b_b_b"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Leggin 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 | -------------------------------------------------------------------------------- /src/dirigera/devices/water_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | class WaterSensorAttributes(Attributes): 7 | battery_percentage: Optional[int] = None 8 | water_leak_detected: bool 9 | 10 | class WaterSensor(Device): 11 | dirigera_client: AbstractSmartHomeHub 12 | attributes: WaterSensorAttributes 13 | 14 | def reload(self) -> WaterSensor: 15 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 16 | return WaterSensor(dirigeraClient=self.dirigera_client, **data) 17 | 18 | def set_name(self, name: str) -> None: 19 | if "customName" not in self.capabilities.can_receive: 20 | raise AssertionError("This sensor does not support the set_name function") 21 | 22 | data = [{"attributes": {"customName": name}}] 23 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 24 | self.attributes.custom_name = name 25 | 26 | def dict_to_water_sensor( 27 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 28 | ) -> WaterSensor: 29 | return WaterSensor(dirigeraClient=dirigera_client, **data) 30 | -------------------------------------------------------------------------------- /src/dirigera/devices/open_close_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class OpenCloseSensorAttributes(Attributes): 8 | is_open: bool 9 | battery_percentage: Optional[int] = None 10 | 11 | class OpenCloseSensor(Device): 12 | dirigera_client: AbstractSmartHomeHub 13 | attributes: OpenCloseSensorAttributes 14 | 15 | def reload(self) -> OpenCloseSensor: 16 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 17 | return OpenCloseSensor(dirigeraClient=self.dirigera_client, **data) 18 | 19 | def set_name(self, name: str) -> None: 20 | if "customName" not in self.capabilities.can_receive: 21 | raise AssertionError("This sensor does not support the set_name function") 22 | 23 | data = [{"attributes": {"customName": name}}] 24 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 25 | self.attributes.custom_name = name 26 | 27 | 28 | def dict_to_open_close_sensor( 29 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 30 | ) -> OpenCloseSensor: 31 | return OpenCloseSensor(dirigeraClient=dirigera_client, **data) 32 | -------------------------------------------------------------------------------- /src/dirigera/devices/occupancy_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class OccupancySensorAttributes(Attributes): 8 | battery_percentage: Optional[int] = None 9 | is_detected: Optional[bool] = None 10 | 11 | class OccupancySensor(Device): 12 | dirigera_client: AbstractSmartHomeHub 13 | attributes: OccupancySensorAttributes 14 | 15 | def reload(self) -> OccupancySensor: 16 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 17 | return OccupancySensor(dirigeraClient=self.dirigera_client, **data) 18 | 19 | def set_name(self, name: str) -> None: 20 | if "customName" not in self.capabilities.can_receive: 21 | raise AssertionError("This sensor does not support the set_name function") 22 | 23 | data = [{"attributes": {"customName": name}}] 24 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 25 | self.attributes.custom_name = name 26 | 27 | 28 | def dict_to_occupancy_sensor( 29 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 30 | ) -> OccupancySensor: 31 | return OccupancySensor(dirigeraClient=dirigera_client, **data) 32 | -------------------------------------------------------------------------------- /src/dirigera/devices/controller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Optional, Dict 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class ControllerAttributes(Attributes): 8 | is_on: Optional[bool] = None 9 | battery_percentage: Optional[int] = None 10 | switch_label: Optional[str] = None 11 | 12 | 13 | class Controller(Device): 14 | dirigera_client: AbstractSmartHomeHub 15 | attributes: ControllerAttributes 16 | 17 | def reload(self) -> Controller: 18 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 19 | return Controller(dirigeraClient=self.dirigera_client, **data) 20 | 21 | def set_name(self, name: str) -> None: 22 | if "customName" not in self.capabilities.can_receive: 23 | raise AssertionError( 24 | "This controller does not support the set_name function" 25 | ) 26 | 27 | data = [{"attributes": {"customName": name}}] 28 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 29 | self.attributes.custom_name = name 30 | 31 | 32 | def dict_to_controller( 33 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 34 | ) -> Controller: 35 | return Controller(dirigeraClient=dirigera_client, **data) 36 | -------------------------------------------------------------------------------- /src/dirigera/devices/motion_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class MotionSensorAttributes(Attributes): 8 | battery_percentage: Optional[int] = None 9 | is_on: bool 10 | light_level: Optional[float] = None 11 | is_detected: Optional[bool] = False 12 | 13 | 14 | class MotionSensor(Device): 15 | dirigera_client: AbstractSmartHomeHub 16 | attributes: MotionSensorAttributes 17 | 18 | def reload(self) -> MotionSensor: 19 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 20 | return MotionSensor(dirigeraClient=self.dirigera_client, **data) 21 | 22 | def set_name(self, name: str) -> None: 23 | if "customName" not in self.capabilities.can_receive: 24 | raise AssertionError("This sensor does not support the set_name function") 25 | 26 | data = [{"attributes": {"customName": name}}] 27 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 28 | self.attributes.custom_name = name 29 | 30 | 31 | def dict_to_motion_sensor( 32 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 33 | ) -> MotionSensor: 34 | return MotionSensor(dirigeraClient=dirigera_client, **data) 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dirigera" 7 | version = "1.2.5" 8 | description = "An unofficial Python client for controlling the IKEA Dirigera Smart Home Hub" 9 | readme = "README.md" 10 | authors = [{ name = "Leggin", email = "legginsun@gmail.com" }] 11 | license = { file = "LICENSE" } 12 | keywords = [ 13 | "python", 14 | "iot", 15 | "smarthome", 16 | "hub", 17 | "lighting", 18 | "ikea", 19 | "tradfri", 20 | "dirigera", 21 | ] 22 | dependencies = [ 23 | "requests >= 2.22.0", 24 | "websocket-client >= 1.0.0", 25 | "pydantic >= 1.10.0", 26 | ] 27 | requires-python = ">=3.7" 28 | classifiers = [ 29 | "Programming Language :: Python :: 3.7", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "License :: OSI Approved :: MIT License", 36 | "Operating System :: OS Independent", 37 | ] 38 | 39 | 40 | [project.optional-dependencies] 41 | dev = ["black", "pytest"] 42 | 43 | [project.scripts] 44 | generate-token = "dirigera.hub.auth:main" 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/Leggin/dirigera" 48 | -------------------------------------------------------------------------------- /src/dirigera/devices/light_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class LightSensorAttributes(Attributes): 8 | battery_percentage: Optional[int] = None 9 | illuminance: Optional[int] = None 10 | max_illuminance: Optional[int] = None 11 | min_illuminance: Optional[int] = None 12 | 13 | class LightSensor(Device): 14 | dirigera_client: AbstractSmartHomeHub 15 | attributes: LightSensorAttributes 16 | 17 | def reload(self) -> LightSensor: 18 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 19 | return LightSensor(dirigeraClient=self.dirigera_client, **data) 20 | 21 | def set_name(self, name: str) -> None: 22 | if "customName" not in self.capabilities.can_receive: 23 | raise AssertionError("This sensor does not support the set_name function") 24 | 25 | data = [{"attributes": {"customName": name}}] 26 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 27 | self.attributes.custom_name = name 28 | 29 | 30 | def dict_to_light_sensor( 31 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 32 | ) -> LightSensor: 33 | return LightSensor(dirigeraClient=dirigera_client, **data) 34 | -------------------------------------------------------------------------------- /src/dirigera/devices/environment_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, Optional 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class EnvironmentSensorAttributes(Attributes): 8 | current_temperature: Optional[float] = None 9 | current_r_h: Optional[int] = None 10 | current_p_m25: Optional[int] = None 11 | max_measured_p_m25: Optional[int] = None 12 | min_measured_p_m25: Optional[int] = None 13 | voc_index: Optional[int] = None 14 | battery_percentage: Optional[int] = None 15 | 16 | 17 | class EnvironmentSensor(Device): 18 | dirigera_client: AbstractSmartHomeHub 19 | attributes: EnvironmentSensorAttributes 20 | 21 | def reload(self) -> EnvironmentSensor: 22 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 23 | return EnvironmentSensor(dirigeraClient=self.dirigera_client, **data) 24 | 25 | def set_name(self, name: str) -> None: 26 | if "customName" not in self.capabilities.can_receive: 27 | raise AssertionError("This sensor does not support the set_name function") 28 | 29 | data = [{"attributes": {"customName": name}}] 30 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 31 | self.attributes.custom_name = name 32 | 33 | 34 | def dict_to_environment_sensor( 35 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 36 | ) -> EnvironmentSensor: 37 | return EnvironmentSensor(dirigeraClient=dirigera_client, **data) 38 | -------------------------------------------------------------------------------- /src/dirigera/hub/abstract_smart_home_hub.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Dict, List, Optional 3 | 4 | 5 | class AbstractSmartHomeHub(abc.ABC): 6 | @abc.abstractmethod 7 | def patch(self, route: str, data: List[Dict[str, Any]]) -> Any: 8 | raise NotImplementedError 9 | 10 | @abc.abstractmethod 11 | def get(self, route: str) -> Any: 12 | raise NotImplementedError 13 | 14 | @abc.abstractmethod 15 | def post(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 16 | raise NotImplementedError 17 | 18 | @abc.abstractmethod 19 | def delete(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 20 | raise NotImplementedError 21 | 22 | 23 | class FakeDirigeraHub(AbstractSmartHomeHub): 24 | def __init__(self) -> None: 25 | self.patch_actions: List = [] 26 | self.post_actions: List = [] 27 | self.get_actions: List = [] 28 | self.get_action_replys: Dict = {} 29 | self.delete_actions: List = [] 30 | 31 | def patch(self, route: str, data: List[Dict[str, Any]]) -> Any: 32 | self.patch_actions.append({"route": route, "data": data}) 33 | return {"route": route, "data": data} 34 | 35 | def get(self, route: str) -> Any: 36 | self.get_actions.append({"route": route}) 37 | return self.get_action_replys[route] 38 | 39 | def post(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 40 | self.post_actions.append({"route": route, "data": data}) 41 | 42 | def delete(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 43 | self.delete_actions.append({"route": route, "data": data}) 44 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python dist to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | 9 | jobs: 10 | test: 11 | name: Run Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 3.10 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: "3.10" 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pylint pytest 23 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 24 | - name: Analysing the code with pylint 25 | run: | 26 | pylint $(git ls-files '*.py') 27 | - name: Test with pytest 28 | run: | 29 | pytest 30 | 31 | build-n-publish: 32 | name: Build and publish Python dist to PyPI 33 | needs: test 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Set up Python 38 | uses: actions/setup-python@v4 39 | with: 40 | python-version: "3.10" 41 | - name: Install pypa/build 42 | run: >- 43 | python -m 44 | pip install 45 | build 46 | --user 47 | - name: Build a binary wheel and a source tarball 48 | run: >- 49 | python -m 50 | build 51 | --sdist 52 | --wheel 53 | --outdir dist/ 54 | . 55 | - name: Publish dist to PyPI 56 | if: startsWith(github.ref, 'refs/tags') 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | with: 59 | password: ${{ secrets.PYPI_API_TOKEN }} 60 | -------------------------------------------------------------------------------- /src/dirigera/devices/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import datetime 3 | from enum import Enum 4 | from typing import Any, Dict, Optional, List 5 | from .base_ikea_model import BaseIkeaModel 6 | 7 | 8 | class StartupEnum(Enum): 9 | START_ON = "startOn" 10 | START_OFF = "startOff" 11 | START_PREVIOUS = "startPrevious" 12 | START_TOGGLE = "startToggle" 13 | 14 | 15 | class Attributes(BaseIkeaModel): 16 | custom_name: str 17 | model: str 18 | manufacturer: str 19 | firmware_version: str 20 | hardware_version: str 21 | serial_number: Optional[str] = None 22 | product_code: Optional[str] = None 23 | ota_status: Optional[str] = None 24 | ota_state: Optional[str] = None 25 | ota_progress: Optional[int] = None 26 | ota_policy: Optional[str] = None 27 | ota_schedule_start: Optional[datetime.time] = None 28 | ota_schedule_end: Optional[datetime.time] = None 29 | 30 | 31 | class Capabilities(BaseIkeaModel): 32 | can_send: List[str] 33 | can_receive: List[str] 34 | 35 | 36 | class Room(BaseIkeaModel): 37 | id: str 38 | name: str 39 | color: str 40 | icon: str 41 | 42 | 43 | class Device(BaseIkeaModel): 44 | id: str 45 | relation_id: Optional[str] = None 46 | type: str 47 | device_type: str 48 | created_at: datetime.datetime 49 | is_reachable: bool 50 | last_seen: datetime.datetime 51 | attributes: Attributes 52 | capabilities: Capabilities 53 | room: Optional[Room] = None 54 | device_set: List 55 | remote_links: List[str] 56 | is_hidden: Optional[bool] = None 57 | 58 | def _reload(self, data: Dict[str, Any]) -> Device: 59 | return Device(**data) 60 | -------------------------------------------------------------------------------- /src/dirigera/devices/blinds.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Optional, Dict 3 | from .device import Attributes, Device 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class BlindAttributes(Attributes): 8 | blinds_current_level: Optional[int] = None 9 | blinds_target_level: Optional[int] = None 10 | blinds_state: Optional[str] = None 11 | battery_percentage: Optional[int] = None 12 | 13 | 14 | class Blind(Device): 15 | dirigera_client: AbstractSmartHomeHub 16 | attributes: BlindAttributes 17 | 18 | def reload(self) -> Blind: 19 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 20 | return Blind(dirigeraClient=self.dirigera_client, **data) 21 | 22 | def set_name(self, name: str) -> None: 23 | if "customName" not in self.capabilities.can_receive: 24 | raise AssertionError("This blind does not support the customName function") 25 | 26 | data = [{"attributes": {"customName": name}}] 27 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 28 | self.attributes.custom_name = name 29 | 30 | def set_target_level(self, target_level: int) -> None: 31 | if "blindsTargetLevel" not in self.capabilities.can_receive: 32 | raise AssertionError( 33 | "This blind does not support the target level function" 34 | ) 35 | 36 | if target_level < 0 or target_level > 100: 37 | raise AssertionError("target_level must be a value between 0 and 100") 38 | 39 | data = [{"attributes": {"blindsTargetLevel": target_level}}] 40 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 41 | self.attributes.blinds_target_level = target_level 42 | 43 | 44 | def dict_to_blind(data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub) -> Blind: 45 | return Blind(dirigeraClient=dirigera_client, **data) 46 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install pylint pytest 32 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 33 | - name: Install setuptools for Python ≥ 3.12 34 | if: matrix.python-version >= '3.12' 35 | run: pip install setuptools 36 | - name: Analysing the code with pylint 37 | run: | 38 | pylint $(git ls-files '*.py') 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | 43 | mypy-test: 44 | name: Run mypy test 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Set up Python 3.12 49 | uses: actions/setup-python@v3 50 | with: 51 | python-version: "3.12" 52 | - name: Install dependencies 53 | run: | 54 | python -m venv venv 55 | source venv/bin/activate 56 | python -m pip install --upgrade pip 57 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 58 | if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi 59 | - name: Analysing the code with pymypylint 60 | run: | 61 | source venv/bin/activate 62 | mypy . 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/dirigera/hub/auth.py: -------------------------------------------------------------------------------- 1 | import string 2 | import hashlib 3 | import random 4 | import socket 5 | import base64 6 | import sys 7 | import requests 8 | from urllib3.exceptions import InsecureRequestWarning 9 | import urllib3 10 | 11 | urllib3.disable_warnings(category=InsecureRequestWarning) 12 | 13 | # requests.packages.urllib3.disable_warnings( # pylint: disable=no-member 14 | # category=InsecureRequestWarning 15 | # ) 16 | 17 | ALPHABET = f"_-~.{string.ascii_letters}{string.digits}" 18 | CODE_LENGTH = 128 19 | 20 | 21 | def random_char(alphabet: str) -> str: 22 | return alphabet[random.randrange(0, len(alphabet))] 23 | 24 | 25 | def random_code(alphabet: str, length: int) -> str: 26 | return "".join([random_char(alphabet) for _ in range(0, length)]) 27 | 28 | 29 | def code_challenge(code_verifier: str) -> str: 30 | sha256_hash = hashlib.sha256() 31 | sha256_hash.update(code_verifier.encode()) 32 | digest = sha256_hash.digest() 33 | sha256_hash_as_base64 = ( 34 | base64.urlsafe_b64encode(digest).rstrip(b"=").decode("us-ascii") 35 | ) 36 | return sha256_hash_as_base64 37 | 38 | 39 | def send_challenge(ip_address: str, code_verifier: str) -> str: 40 | auth_url = f"https://{ip_address}:8443/v1/oauth/authorize" 41 | params = { 42 | "audience": "homesmart.local", 43 | "response_type": "code", 44 | "code_challenge": code_challenge(code_verifier), 45 | "code_challenge_method": "S256", 46 | } 47 | response = requests.get(auth_url, params=params, verify=False, timeout=10) 48 | response.raise_for_status() 49 | return response.json()["code"] 50 | 51 | 52 | def get_token(ip_address: str, code: str, code_verifier: str) -> str: 53 | data = str( 54 | "code=" 55 | + code 56 | + "&name=" 57 | + socket.gethostname() 58 | + "&grant_type=" 59 | + "authorization_code" 60 | + "&code_verifier=" 61 | + code_verifier 62 | ) 63 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 64 | token_url = f"https://{ip_address}:8443/v1/oauth/token" 65 | 66 | response = requests.post( 67 | token_url, headers=headers, data=data, verify=False, timeout=10 68 | ) 69 | response.raise_for_status() 70 | return response.json()["access_token"] 71 | 72 | 73 | def main() -> None: 74 | if len(sys.argv) > 1: 75 | ip_address = sys.argv[1] 76 | else: 77 | ip_address = input("Input the ip address of your Dirigera then hit ENTER ...\n") 78 | code_verifier = random_code(ALPHABET, CODE_LENGTH) 79 | code = send_challenge(ip_address, code_verifier) 80 | input("Press the action button on Dirigera then hit ENTER ...") 81 | token = get_token(ip_address, code, code_verifier) 82 | print("Your TOKEN :") 83 | print(token) 84 | -------------------------------------------------------------------------------- /src/dirigera/devices/outlet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import datetime 3 | from typing import Any, Optional, Dict 4 | from .device import Attributes, Device, StartupEnum 5 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 6 | 7 | # Quirks: 8 | # TRADFRI control outlet / IKEA of Sweden: 9 | # device has lightLevel attribute and canReceive capability, neither of 10 | # them affect the state of relay. 11 | 12 | 13 | class OutletAttributes(Attributes): 14 | is_on: bool 15 | startup_on_off: Optional[StartupEnum] = None 16 | status_light: Optional[bool] = None 17 | identify_period: Optional[int] = None 18 | permitting_join: Optional[bool] = None 19 | energy_consumed_at_last_reset: Optional[float] = None 20 | current_active_power: Optional[float] = None 21 | current_amps: Optional[float] = None 22 | current_voltage: Optional[float] = None 23 | total_energy_consumed: Optional[float] = None 24 | total_energy_consumed_last_updated: Optional[datetime.datetime] = None 25 | time_of_last_energy_reset: Optional[datetime.datetime] = None 26 | 27 | 28 | class Outlet(Device): 29 | dirigera_client: AbstractSmartHomeHub 30 | attributes: OutletAttributes 31 | 32 | def reload(self) -> Outlet: 33 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 34 | return Outlet(dirigeraClient=self.dirigera_client, **data) 35 | 36 | def set_name(self, name: str) -> None: 37 | if "customName" not in self.capabilities.can_receive: 38 | raise AssertionError( 39 | "This device does not support the customName capability" 40 | ) 41 | 42 | data = [{"attributes": {"customName": name}}] 43 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 44 | self.attributes.custom_name = name 45 | 46 | def set_on(self, outlet_on: bool) -> None: 47 | if "isOn" not in self.capabilities.can_receive: 48 | raise AssertionError("This device does not support the isOn function") 49 | 50 | data = [{"attributes": {"isOn": outlet_on}}] 51 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 52 | self.attributes.is_on = outlet_on 53 | 54 | def set_startup_behaviour(self, behaviour: StartupEnum) -> None: 55 | """ 56 | Sets the behaviour of the device in case of a power outage. 57 | When set to START_ON the device will turn on once the power is back. 58 | When set to START_OFF the device will stay off once the power is back. 59 | When set to START_PREVIOUS the device will resume its state at power outage. 60 | When set to START_TOGGLE, a sequence of power-off -> power-on, will toggle the device state 61 | """ 62 | data = [{"attributes": {"startupOnOff": behaviour.value}}] 63 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 64 | self.attributes.startup_on_off = behaviour 65 | 66 | 67 | def dict_to_outlet( 68 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 69 | ) -> Outlet: 70 | return Outlet(dirigeraClient=dirigera_client, **data) 71 | -------------------------------------------------------------------------------- /tests/test_occupancy_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import pytest 4 | 5 | from src.dirigera.devices.occupancy_sensor import OccupancySensor, dict_to_occupancy_sensor 6 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 7 | 8 | 9 | @pytest.fixture(name="fake_client") 10 | def fixture_fake_client() -> FakeDirigeraHub: 11 | return FakeDirigeraHub() 12 | 13 | 14 | @pytest.fixture(name="occupancy_sensor_dict") 15 | def fixture_occupancy_sensor_dict() -> Dict: 16 | return { 17 | "id": "11111111-1111-1111-1111-111111111111_1", 18 | "type": "sensor", 19 | "deviceType": "occupancySensor", 20 | "createdAt": "2023-12-14T18:28:57.000Z", 21 | "isReachable": True, 22 | "lastSeen": "2023-12-14T17:30:48.000Z", 23 | "attributes": { 24 | "customName": "Occupancy", 25 | "firmwareVersion": "1.0.0", 26 | "hardwareVersion": "1", 27 | "manufacturer": "IKEA of Sweden", 28 | "model": "SOME OCCUPANCY SENSOR", 29 | "productCode": "E0000", 30 | "serialNumber": "AAAAAAAAAAAAAAAA", 31 | "batteryPercentage": 99, 32 | "isDetected": False, 33 | }, 34 | "capabilities": {"canSend": [], "canReceive": ["customName"]}, 35 | "room": { 36 | "id": "acaff5ef-2840-45a9-bbc9-19aa77553369", 37 | "name": "Living room", 38 | "color": "ikea_green_no_65", 39 | "icon": "rooms_sofa", 40 | }, 41 | "deviceSet": [], 42 | "remoteLinks": [], 43 | "isHidden": False, 44 | } 45 | 46 | 47 | @pytest.fixture(name="fake_occupancy_sensor") 48 | def fixture_fake_occupancy_sensor( 49 | occupancy_sensor_dict: Dict, fake_client: FakeDirigeraHub 50 | ) -> OccupancySensor: 51 | return OccupancySensor(dirigeraClient=fake_client, **occupancy_sensor_dict) 52 | 53 | 54 | def test_set_occupancy_sensor_name( 55 | fake_occupancy_sensor: OccupancySensor, fake_client: FakeDirigeraHub 56 | ) -> None: 57 | new_name = "occupancy_sensor_name" 58 | assert fake_occupancy_sensor.attributes.custom_name != new_name 59 | fake_occupancy_sensor.set_name(new_name) 60 | action = fake_client.patch_actions.pop() 61 | assert action["route"] == f"/devices/{fake_occupancy_sensor.id}" 62 | assert action["data"] == [{"attributes": {"customName": new_name}}] 63 | assert fake_occupancy_sensor.attributes.custom_name == new_name 64 | 65 | 66 | def test_dict_to_occupancy_sensor( 67 | occupancy_sensor_dict: Dict, fake_client: FakeDirigeraHub 68 | ) -> None: 69 | sensor = dict_to_occupancy_sensor(occupancy_sensor_dict, fake_client) 70 | assert sensor.dirigera_client == fake_client 71 | assert sensor.id == occupancy_sensor_dict["id"] 72 | assert sensor.is_reachable == occupancy_sensor_dict["isReachable"] 73 | assert sensor.attributes.custom_name == occupancy_sensor_dict["attributes"]["customName"] 74 | assert ( 75 | sensor.attributes.battery_percentage 76 | == occupancy_sensor_dict["attributes"]["batteryPercentage"] 77 | ) 78 | assert sensor.attributes.is_detected == occupancy_sensor_dict["attributes"]["isDetected"] 79 | -------------------------------------------------------------------------------- /tests/test_light_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import pytest 4 | 5 | from src.dirigera.devices.light_sensor import LightSensor, dict_to_light_sensor 6 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 7 | 8 | 9 | @pytest.fixture(name="fake_client") 10 | def fixture_fake_client() -> FakeDirigeraHub: 11 | return FakeDirigeraHub() 12 | 13 | 14 | @pytest.fixture(name="light_sensor_dict") 15 | def fixture_light_sensor_dict() -> Dict: 16 | return { 17 | "id": "22222222-2222-2222-2222-222222222222_1", 18 | "type": "sensor", 19 | "deviceType": "lightSensor", 20 | "createdAt": "2023-12-14T18:28:57.000Z", 21 | "isReachable": True, 22 | "lastSeen": "2023-12-14T17:30:48.000Z", 23 | "attributes": { 24 | "customName": "Light Sensor", 25 | "firmwareVersion": "1.0.0", 26 | "hardwareVersion": "1", 27 | "manufacturer": "IKEA of Sweden", 28 | "model": "SOME LIGHT SENSOR", 29 | "productCode": "E0001", 30 | "serialNumber": "BBBBBBBBBBBBBBBB", 31 | "batteryPercentage": 88, 32 | "illuminance": 21790, 33 | "maxIlluminance": 40001, 34 | "minIlluminance": 1, 35 | }, 36 | "capabilities": {"canSend": [], "canReceive": ["customName"]}, 37 | "room": { 38 | "id": "acaff5ef-2840-45a9-bbc9-19aa77553369", 39 | "name": "Living room", 40 | "color": "ikea_green_no_65", 41 | "icon": "rooms_sofa", 42 | }, 43 | "deviceSet": [], 44 | "remoteLinks": [], 45 | "isHidden": False, 46 | } 47 | 48 | 49 | @pytest.fixture(name="fake_light_sensor") 50 | def fixture_fake_light_sensor( 51 | light_sensor_dict: Dict, fake_client: FakeDirigeraHub 52 | ) -> LightSensor: 53 | return LightSensor(dirigeraClient=fake_client, **light_sensor_dict) 54 | 55 | 56 | def test_set_light_sensor_name( 57 | fake_light_sensor: LightSensor, fake_client: FakeDirigeraHub 58 | ) -> None: 59 | new_name = "light_sensor_name" 60 | assert fake_light_sensor.attributes.custom_name != new_name 61 | fake_light_sensor.set_name(new_name) 62 | action = fake_client.patch_actions.pop() 63 | assert action["route"] == f"/devices/{fake_light_sensor.id}" 64 | assert action["data"] == [{"attributes": {"customName": new_name}}] 65 | assert fake_light_sensor.attributes.custom_name == new_name 66 | 67 | 68 | def test_dict_to_light_sensor(light_sensor_dict: Dict, fake_client: FakeDirigeraHub) -> None: 69 | sensor = dict_to_light_sensor(light_sensor_dict, fake_client) 70 | assert sensor.dirigera_client == fake_client 71 | assert sensor.id == light_sensor_dict["id"] 72 | assert sensor.is_reachable == light_sensor_dict["isReachable"] 73 | assert sensor.attributes.custom_name == light_sensor_dict["attributes"]["customName"] 74 | assert sensor.attributes.battery_percentage == light_sensor_dict["attributes"]["batteryPercentage"] 75 | assert sensor.attributes.illuminance == light_sensor_dict["attributes"]["illuminance"] 76 | assert sensor.attributes.max_illuminance == light_sensor_dict["attributes"]["maxIlluminance"] 77 | assert sensor.attributes.min_illuminance == light_sensor_dict["attributes"]["minIlluminance"] 78 | -------------------------------------------------------------------------------- /src/dirigera/devices/air_purifier.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import Enum 3 | from typing import Any, Dict 4 | from .device import Attributes, Device 5 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 6 | 7 | class FanModeEnum(Enum): 8 | OFF = "off" 9 | ON = "on" 10 | LOW = "low" 11 | MEDIUM = "medium" 12 | HIGH = "high" 13 | AUTO = "auto" 14 | 15 | class AirPurifierAttributes(Attributes): 16 | """canReceive""" 17 | fan_mode: FanModeEnum 18 | fan_mode_sequence: str 19 | motor_state: int 20 | child_lock: bool 21 | status_light: bool 22 | """readOnly""" 23 | motor_runtime: int 24 | filter_alarm_status: bool 25 | filter_elapsed_time: int 26 | filter_lifetime: int 27 | current_p_m25: int 28 | 29 | class AirPurifier(Device): 30 | dirigera_client: AbstractSmartHomeHub 31 | attributes: AirPurifierAttributes 32 | 33 | def reload(self) -> AirPurifier: 34 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 35 | return AirPurifier(dirigeraClient=self.dirigera_client, **data) 36 | 37 | def set_name(self, name: str) -> None: 38 | if "customName" not in self.capabilities.can_receive: 39 | raise AssertionError("This airpurifier does not support the set_name function") 40 | 41 | data = [{"attributes": {"customName": name}}] 42 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 43 | self.attributes.custom_name = name 44 | 45 | def set_fan_mode(self, fan_mode: FanModeEnum) -> None: 46 | data = [{"attributes": {"fanMode": fan_mode.value}}] 47 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 48 | self.attributes.fan_mode = fan_mode 49 | 50 | def set_motor_state(self, motor_state: int) -> None: 51 | """ 52 | Sets the fan behaviour. 53 | Values 0 to 50 allowed. 54 | 0 == off 55 | 1 == auto 56 | """ 57 | desired_motor_state = int(motor_state) 58 | if desired_motor_state < 0 or desired_motor_state > 50: 59 | raise ValueError("Motor state must be a value between 0 and 50") 60 | 61 | data = [{"attributes": {"motorState": desired_motor_state}}] 62 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 63 | self.attributes.motor_state = desired_motor_state 64 | 65 | def set_child_lock(self, child_lock: bool) -> None: 66 | if "childLock" not in self.capabilities.can_receive: 67 | raise AssertionError("This air-purifier does not support the child lock function") 68 | 69 | data = [{"attributes": {"childLock": child_lock}}] 70 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 71 | self.attributes.child_lock = child_lock 72 | 73 | def set_status_light(self, light_state: bool) -> None: 74 | data = [{"attributes": {"statusLight": light_state}}] 75 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 76 | self.attributes.status_light = light_state 77 | 78 | def dict_to_air_purifier(data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub) -> AirPurifier: 79 | return AirPurifier( 80 | dirigeraClient=dirigera_client, 81 | **data 82 | ) 83 | -------------------------------------------------------------------------------- /tests/test_environment_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.environment_sensor import ( 5 | EnvironmentSensor, 6 | dict_to_environment_sensor, 7 | ) 8 | 9 | 10 | @pytest.fixture(name="fake_client") 11 | def fixture_fake_client() -> FakeDirigeraHub: 12 | return FakeDirigeraHub() 13 | 14 | 15 | @pytest.fixture(name="sensor_dict") 16 | def fixture_sensor_dict() -> Dict: 17 | return { 18 | "id": "75863f6a-d850-47f1-8a00-e31acdcae0e8_1", 19 | "type": "sensor", 20 | "deviceType": "environmentSensor", 21 | "createdAt": "2023-04-04T13:13:25.000Z", 22 | "isReachable": True, 23 | "lastSeen": "2023-10-28T14:19:24.000Z", 24 | "attributes": { 25 | "customName": "Envsensor", 26 | "model": "VINDSTYRKA", 27 | "manufacturer": "IKEA of Sweden", 28 | "firmwareVersion": "1.0.11", 29 | "hardwareVersion": "1", 30 | "serialNumber": "F4B3B1FFFE00101E", 31 | "productCode": "E2112", 32 | "currentTemperature": 21.1, 33 | "currentRH": 61, 34 | "currentPM25": 1, 35 | "maxMeasuredPM25": 999, 36 | "minMeasuredPM25": 0, 37 | "vocIndex": 63, 38 | "identifyStarted": "2000-01-01T00:00:00.000Z", 39 | "otaStatus": "upToDate", 40 | "otaState": "readyToCheck", 41 | "otaProgress": 0, 42 | "otaPolicy": "autoUpdate", 43 | "otaScheduleStart": "00:00", 44 | "otaScheduleEnd": "00:00", 45 | }, 46 | "capabilities": {"canSend": [], "canReceive": ["customName"]}, 47 | "room": { 48 | "id": "acaff5ef-2840-45a9-bbc9-19aa77553369", 49 | "name": "Living room", 50 | "color": "ikea_green_no_65", 51 | "icon": "rooms_sofa", 52 | }, 53 | "deviceSet": [], 54 | "remoteLinks": [], 55 | "isHidden": False, 56 | } 57 | 58 | 59 | @pytest.fixture(name="fake_sensor") 60 | def fixture_sensor( 61 | fake_client: FakeDirigeraHub, sensor_dict: Dict 62 | ) -> EnvironmentSensor: 63 | return EnvironmentSensor(dirigeraClient=fake_client, **sensor_dict) 64 | 65 | 66 | def test_set_name(fake_sensor: EnvironmentSensor, fake_client: FakeDirigeraHub) -> None: 67 | new_name = "staubsensor" 68 | fake_sensor.set_name(new_name) 69 | action = fake_client.patch_actions.pop() 70 | assert action["route"] == f"/devices/{fake_sensor.id}" 71 | assert action["data"] == [{"attributes": {"customName": new_name}}] 72 | assert fake_sensor.attributes.custom_name == new_name 73 | 74 | 75 | def test_dict_to_sensor(fake_client: FakeDirigeraHub, sensor_dict: Dict) -> None: 76 | sensor = dict_to_environment_sensor(sensor_dict, fake_client) 77 | assert sensor.id == sensor_dict["id"] 78 | assert sensor.is_reachable == sensor_dict["isReachable"] 79 | assert sensor.attributes.custom_name == sensor_dict["attributes"]["customName"] 80 | assert ( 81 | sensor.attributes.firmware_version 82 | == sensor_dict["attributes"]["firmwareVersion"] 83 | ) 84 | assert ( 85 | sensor.attributes.hardware_version 86 | == sensor_dict["attributes"]["hardwareVersion"] 87 | ) 88 | assert sensor.attributes.model == sensor_dict["attributes"]["model"] 89 | assert sensor.attributes.serial_number == sensor_dict["attributes"]["serialNumber"] 90 | assert ( 91 | sensor.attributes.current_temperature 92 | == sensor_dict["attributes"]["currentTemperature"] 93 | ) 94 | assert sensor.attributes.current_r_h == sensor_dict["attributes"]["currentRH"] 95 | assert sensor.attributes.current_p_m25 == sensor_dict["attributes"]["currentPM25"] 96 | assert ( 97 | sensor.attributes.max_measured_p_m25 98 | == sensor_dict["attributes"]["maxMeasuredPM25"] 99 | ) 100 | assert ( 101 | sensor.attributes.min_measured_p_m25 102 | == sensor_dict["attributes"]["minMeasuredPM25"] 103 | ) 104 | assert sensor.attributes.voc_index == sensor_dict["attributes"]["vocIndex"] 105 | assert sensor.capabilities.can_receive == sensor_dict["capabilities"]["canReceive"] 106 | assert sensor.room.id == sensor_dict["room"]["id"] 107 | assert sensor.room.name == sensor_dict["room"]["name"] 108 | assert sensor.attributes.manufacturer == sensor_dict["attributes"]["manufacturer"] 109 | -------------------------------------------------------------------------------- /tests/test_water_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.water_sensor import dict_to_water_sensor 5 | from src.dirigera.devices.water_sensor import WaterSensor 6 | 7 | @pytest.fixture(name="fake_client") 8 | def fixture_fake_client() -> FakeDirigeraHub: 9 | return FakeDirigeraHub() 10 | 11 | 12 | @pytest.fixture(name="fake_water_sensor_dict_non_ikea") 13 | def fixture_water_sensor_dict_non_ikea() -> Dict: 14 | return { 15 | "id": "2b107b0b-73f0-4809-a900-4783273d7104_1", 16 | "type": "sensor", 17 | "deviceType": "waterSensor", 18 | "createdAt": "2024-04-17T12:19:50.000Z", 19 | "isReachable": True, 20 | "lastSeen": "2024-04-17T12:34:42.000Z", 21 | "attributes": { 22 | "customName": "Watermelder", 23 | "firmwareVersion": "", 24 | "hardwareVersion": "", 25 | "manufacturer": "SONOFF", 26 | "model": "Fake SONOFF Model", 27 | "productCode": "E2202", 28 | "serialNumber": "0", 29 | "waterLeakDetected": False, 30 | "permittingJoin": False, 31 | }, 32 | "capabilities": { 33 | "canSend": [], 34 | "canReceive": [ 35 | "customName" 36 | ] 37 | }, 38 | "room": { 39 | "id": "f1743e4c-3a87-4f6b-90a4-3e915b8ed753", 40 | "name": "Zolder", 41 | "color": "ikea_pink_no_8", 42 | "icon": "rooms_washing_machine" 43 | }, 44 | "deviceSet": [], 45 | "remoteLinks": [], 46 | "isHidden": False 47 | } 48 | 49 | 50 | 51 | @pytest.fixture(name="fake_water_sensor_dict") 52 | def fixture_water_sensor_dict() -> Dict: 53 | return { 54 | "id": "2b107b0b-73f0-4809-a900-4783273d7104_1", 55 | "type": "sensor", 56 | "deviceType": "waterSensor", 57 | "createdAt": "2024-04-17T12:19:50.000Z", 58 | "isReachable": True, 59 | "lastSeen": "2024-04-17T12:34:42.000Z", 60 | "attributes": { 61 | "customName": "Watermelder", 62 | "firmwareVersion": "1.0.7", 63 | "hardwareVersion": "1", 64 | "manufacturer": "IKEA of Sweden", 65 | "model": "BADRING Water Leakage Sensor", 66 | "productCode": "E2202", 67 | "serialNumber": "3410F4FFFE8F815D", 68 | "batteryPercentage": 100, 69 | "waterLeakDetected": True, 70 | "permittingJoin": False, 71 | "otaPolicy": "autoUpdate", 72 | "otaProgress": 0, 73 | "otaScheduleEnd": "00:00", 74 | "otaScheduleStart": "00:00", 75 | "otaState": "readyToCheck", 76 | "otaStatus": "upToDate" 77 | }, 78 | "capabilities": { 79 | "canSend": [], 80 | "canReceive": [ 81 | "customName" 82 | ] 83 | }, 84 | "room": { 85 | "id": "f1743e4c-3a87-4f6b-90a4-3e915b8ed753", 86 | "name": "Zolder", 87 | "color": "ikea_pink_no_8", 88 | "icon": "rooms_washing_machine" 89 | }, 90 | "deviceSet": [], 91 | "remoteLinks": [], 92 | "isHidden": False 93 | } 94 | 95 | 96 | @pytest.fixture(name="fake_water_sensor") 97 | def fixture_water_sensor(fake_water_sensor_dict: Dict, fake_client: FakeDirigeraHub) -> WaterSensor: 98 | return WaterSensor(dirigeraClient=fake_client, **fake_water_sensor_dict) 99 | 100 | def test_set_name(fake_water_sensor: WaterSensor, fake_client: FakeDirigeraHub) -> None: 101 | new_name = "teapot" 102 | fake_water_sensor.set_name(new_name) 103 | action = fake_client.patch_actions.pop() 104 | assert action["route"] == f"/devices/{fake_water_sensor.id}" 105 | assert action["data"] == [{"attributes": {"customName": new_name}}] 106 | assert fake_water_sensor.attributes.custom_name == new_name 107 | 108 | def test_dict_to_water_sensor(fake_water_sensor_dict: Dict, fake_client: FakeDirigeraHub) -> None: 109 | water_sensor = dict_to_water_sensor(fake_water_sensor_dict, fake_client) 110 | 111 | assert water_sensor.dirigera_client == fake_client 112 | assert water_sensor.id == fake_water_sensor_dict["id"] 113 | assert water_sensor.is_reachable == fake_water_sensor_dict["isReachable"] 114 | assert water_sensor.attributes.battery_percentage == 100 115 | assert water_sensor.attributes.water_leak_detected 116 | 117 | def test_dict_to_water_sensor_non_ikea(fake_water_sensor_dict_non_ikea: Dict, fake_client: FakeDirigeraHub) -> None: 118 | water_sensor = dict_to_water_sensor(fake_water_sensor_dict_non_ikea, fake_client) 119 | 120 | assert water_sensor.attributes.battery_percentage is None 121 | -------------------------------------------------------------------------------- /src/dirigera/devices/scene.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import datetime 3 | from enum import Enum 4 | from typing import Dict, Any, List, Optional, Union 5 | from .base_ikea_model import BaseIkeaModel 6 | from .device import Attributes 7 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 8 | 9 | 10 | class SceneAttributes(Attributes): 11 | scene_id: str 12 | name: str 13 | icon: str 14 | last_completed: Optional[str] = None 15 | last_triggered: Optional[str] = None 16 | last_undo: Optional[str] = None 17 | 18 | 19 | class Icon(Enum): 20 | SCENES_ARRIVE_HOME = "scenes_arrive_home" 21 | SCENES_BOOK = "scenes_book" 22 | SCENES_BRIEFCASE = "scenes_briefcase" 23 | SCENES_BRIGHTNESS_UP = "scenes_brightness_up" 24 | SCENES_BROOM = "scenes_broom" 25 | SCENES_CAKE = "scenes_cake" 26 | SCENES_CLAPPER = "scenes_clapper" 27 | SCENES_CLEAN_SPARKLES = "scenes_clean_sparkles" 28 | SCENES_CUTLERY = "scenes_cutlery" 29 | SCENES_DISCO_BALL = "scenes_disco_ball" 30 | SCENES_GAME_PAD = "scenes_game_pad" 31 | SCENES_GIFT_BAG = "scenes_gift_bag" 32 | SCENES_GIFT_BOX = "scenes_gift_box" 33 | SCENES_HEADPHONES = "scenes_headphones" 34 | SCENES_HEART = "scenes_heart" 35 | SCENES_HOME_FILLED = "scenes_home_filled" 36 | SCENES_HOT_DRINK = "scenes_hot_drink" 37 | SCENES_LADLE = "scenes_ladle" 38 | SCENES_LEAF = "scenes_leaf" 39 | SCENES_LEAVE_HOME = "scenes_leave_home" 40 | SCENES_MOON = "scenes_moon" 41 | SCENES_MUSIC_NOTE = "scenes_music_note" 42 | SCENES_PAINTING = "scenes_painting" 43 | SCENES_POPCORN = "scenes_popcorn" 44 | SCENES_POT_WITH_LID = "scenes_pot_with_lid" 45 | SCENES_SPEAKER_GENERIC = "scenes_speaker_generic" 46 | SCENES_SPRAY_BOTTLE = "scenes_spray_bottle" 47 | SCENES_SUITCASE = "scenes_suitcase" 48 | SCENES_SUITCASE_2 = "scenes_suitcase_2" 49 | SCENES_SUN_HORIZON = "scenes_sun_horizon" 50 | SCENES_TREE = "scenes_tree" 51 | SCENES_TROPHY = "scenes_trophy" 52 | SCENES_WAKE_UP = "scenes_wake_up" 53 | SCENES_WEIGHTS = "scenes_weights" 54 | SCENES_YOGA = "scenes_yoga" 55 | SCENES_COLD_DRINK_CONTENTS = "scenes_cold_drink_contents" 56 | SCENES_FLAME = "scenes_flame" 57 | SCENES_SNOWFLAKE = "scenes_snowflake" 58 | 59 | 60 | class Info(BaseIkeaModel): 61 | name: str 62 | icon: Icon 63 | 64 | 65 | class EndTriggerEvent(BaseIkeaModel): 66 | type: str 67 | trigger: TriggerDetails 68 | 69 | 70 | class Trigger(BaseIkeaModel): 71 | id: Optional[str] = ( 72 | None # Optional to allow creation of Trigger instances for create_scene() 73 | ) 74 | type: str 75 | triggered_at: Optional[datetime.datetime] = None 76 | disabled: bool 77 | trigger: Optional[TriggerDetails] = None 78 | next_trigger_at: Optional[datetime.datetime] = None 79 | end_trigger: Optional[EndTriggerEvent] = None 80 | end_trigger_event: Optional[EndTriggerEvent] = None 81 | 82 | 83 | class TriggerDetails(BaseIkeaModel): 84 | days: Optional[List[str]] = None 85 | time: Optional[str] = None 86 | controllerType: Optional[ControllerType] = None 87 | buttonIndex: Optional[int] = None 88 | clickPattern: Optional[ClickPattern] = None 89 | deviceId: Optional[str] = None 90 | offset: Optional[int] = None 91 | type: Optional[str] = None 92 | 93 | 94 | class ControllerType(Enum): 95 | SHORTCUT_CONTROLLER = "shortcutController" 96 | 97 | 98 | class ClickPattern(Enum): 99 | LONG_PRESS = "longPress" 100 | DOUBLE_PRESS = "doublePress" 101 | SINGLE_PRESS = "singlePress" 102 | 103 | 104 | class ActionAttributes(BaseIkeaModel, extra="allow"): 105 | is_on: Optional[bool] = None 106 | 107 | 108 | class Action(BaseIkeaModel): 109 | id: str 110 | type: str 111 | enabled: Optional[bool] = None 112 | attributes: Optional[ActionAttributes] = None 113 | 114 | 115 | class SceneType(Enum): 116 | USER_SCENE = "userScene" 117 | CUSTOM_SCENE = "customScene" 118 | PLAYLIST_SCENE = "playlistScene" 119 | WAKEUP_SCENE = "wakeUpScene" 120 | 121 | 122 | class Scene(BaseIkeaModel): 123 | dirigera_client: AbstractSmartHomeHub 124 | id: str 125 | type: SceneType 126 | info: Info 127 | triggers: List[Trigger] 128 | actions: List[Action] 129 | created_at: datetime.datetime 130 | last_completed: Optional[datetime.datetime] = None 131 | last_triggered: Optional[datetime.datetime] = None 132 | last_undo: Optional[datetime.datetime] = None 133 | commands: List[Union[str, Dict[str, Any]]] 134 | undo_allowed_duration: int 135 | 136 | def reload(self) -> Scene: 137 | data = self.dirigera_client.get(route=f"/scenes/{self.id}") 138 | return Scene(dirigeraClient=self.dirigera_client, **data) 139 | 140 | def trigger(self) -> None: 141 | self.dirigera_client.post(route=f"/scenes/{self.id}/trigger") 142 | 143 | def undo(self) -> None: 144 | self.dirigera_client.post(route=f"/scenes/{self.id}/undo") 145 | 146 | 147 | def dict_to_scene(data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub) -> Scene: 148 | return Scene(dirigeraClient=dirigera_client, **data) 149 | -------------------------------------------------------------------------------- /tests/test_open_close_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.open_close_sensor import ( 5 | OpenCloseSensor, 6 | dict_to_open_close_sensor, 7 | ) 8 | 9 | 10 | @pytest.fixture(name="fake_client") 11 | def fixture_fake_client() -> FakeDirigeraHub: 12 | return FakeDirigeraHub() 13 | 14 | 15 | @pytest.fixture(name="fake_sensor") 16 | def fixture_sensor(fake_client: FakeDirigeraHub) -> OpenCloseSensor: 17 | return OpenCloseSensor( 18 | dirigeraClient=fake_client, 19 | **{ 20 | "id": "abc123", 21 | "type": "sensor", 22 | "deviceType": "openclose ensor", 23 | "createdAt": "2023-01-07T20:07:19.000Z", 24 | "isReachable": True, 25 | "lastSeen": "2023-10-28T04:42:15.000Z", 26 | "customIcon": "lighting_nightstand_light", 27 | "attributes": { 28 | "customName": "Sensor 1", 29 | "model": "Wireless Door/Window Sensor", 30 | "manufacturer": "SONOFF", 31 | "firmwareVersion": "1.0.11", 32 | "hardwareVersion": "1", 33 | "serialNumber": "00124B0029121E50", 34 | "productCode": "SNZB-04", 35 | "isOpen": False, 36 | "otaStatus": "upToDate", 37 | "otaState": "readyToCheck", 38 | "otaProgress": 0, 39 | "otaPolicy": "autoUpdate", 40 | "otaScheduleStart": "00:00", 41 | "otaScheduleEnd": "00:00", 42 | }, 43 | "room": { 44 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 45 | "name": "upstairs", 46 | "color": "ikea_yellow_no_24", 47 | "icon": "lamp", 48 | }, 49 | "deviceSet": [], 50 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 51 | "isHidden": False, 52 | "capabilities": {"canSend": [], "canReceive": ["customName"]}, 53 | }, 54 | ) 55 | 56 | 57 | @pytest.fixture(name="sensor_dict") 58 | def fixture_sensor_dict() -> Dict: 59 | return { 60 | "id": "abc123", 61 | "type": "sensor", 62 | "deviceType": "openclose ensor", 63 | "createdAt": "2023-01-07T20:07:19.000Z", 64 | "isReachable": True, 65 | "lastSeen": "2023-10-28T04:42:15.000Z", 66 | "customIcon": "lighting_nightstand_light", 67 | "attributes": { 68 | "customName": "Sensor 1", 69 | "model": "Wireless Door/Window Sensor", 70 | "manufacturer": "SONOFF", 71 | "firmwareVersion": "1.0.11", 72 | "hardwareVersion": "1", 73 | "serialNumber": "00124B0029121E50", 74 | "productCode": "SNZB-04", 75 | "isOpen": False, 76 | "otaStatus": "upToDate", 77 | "otaState": "readyToCheck", 78 | "otaProgress": 0, 79 | "otaPolicy": "autoUpdate", 80 | "otaScheduleStart": "00:00", 81 | "otaScheduleEnd": "00:00", 82 | }, 83 | "room": { 84 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 85 | "name": "upstairs", 86 | "color": "ikea_yellow_no_24", 87 | "icon": "lamp", 88 | }, 89 | "deviceSet": [], 90 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 91 | "isHidden": False, 92 | "capabilities": {"canSend": [], "canReceive": ["customName"]}, 93 | } 94 | 95 | 96 | def test_set_name(fake_sensor: OpenCloseSensor, fake_client: FakeDirigeraHub) -> None: 97 | new_name = "staubsensor" 98 | fake_sensor.set_name(new_name) 99 | action = fake_client.patch_actions.pop() 100 | assert action["route"] == f"/devices/{fake_sensor.id}" 101 | assert action["data"] == [{"attributes": {"customName": new_name}}] 102 | assert fake_sensor.attributes.custom_name == new_name 103 | 104 | 105 | def test_dict_to_sensor(fake_client: FakeDirigeraHub, sensor_dict: Dict) -> None: 106 | sensor = dict_to_open_close_sensor(sensor_dict, fake_client) 107 | assert sensor.id == sensor_dict["id"] 108 | assert sensor.is_reachable == sensor_dict["isReachable"] 109 | assert sensor.attributes.custom_name == sensor_dict["attributes"]["customName"] 110 | assert ( 111 | sensor.attributes.firmware_version 112 | == sensor_dict["attributes"]["firmwareVersion"] 113 | ) 114 | assert ( 115 | sensor.attributes.hardware_version 116 | == sensor_dict["attributes"]["hardwareVersion"] 117 | ) 118 | assert sensor.attributes.model == sensor_dict["attributes"]["model"] 119 | assert sensor.attributes.serial_number == sensor_dict["attributes"]["serialNumber"] 120 | assert sensor.attributes.is_open == sensor_dict["attributes"]["isOpen"] 121 | assert sensor.capabilities.can_receive == sensor_dict["capabilities"]["canReceive"] 122 | assert sensor.room.id == sensor_dict["room"]["id"] 123 | assert sensor.room.name == sensor_dict["room"]["name"] 124 | assert sensor.attributes.manufacturer == sensor_dict["attributes"]["manufacturer"] 125 | -------------------------------------------------------------------------------- /src/dirigera/devices/light.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Optional, Dict 3 | from .device import Attributes, Device, StartupEnum 4 | from ..hub.abstract_smart_home_hub import AbstractSmartHomeHub 5 | 6 | 7 | class LightAttributes(Attributes): 8 | startup_on_off: Optional[StartupEnum] = None 9 | is_on: bool 10 | light_level: Optional[int] = None 11 | color_temperature: Optional[int] = None 12 | color_temperature_min: Optional[int] = None 13 | color_temperature_max: Optional[int] = None 14 | color_hue: Optional[float] = None 15 | color_saturation: Optional[float] = None 16 | 17 | 18 | class Light(Device): 19 | dirigera_client: AbstractSmartHomeHub 20 | attributes: LightAttributes 21 | 22 | def reload(self) -> Light: 23 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 24 | return Light(dirigeraClient=self.dirigera_client, **data) 25 | 26 | def set_name(self, name: str) -> None: 27 | if "customName" not in self.capabilities.can_receive: 28 | raise AssertionError("This lamp does not support the swith-off function") 29 | 30 | data = [{"attributes": {"customName": name}}] 31 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 32 | self.attributes.custom_name = name 33 | 34 | def set_light(self, lamp_on: bool) -> None: 35 | if "isOn" not in self.capabilities.can_receive: 36 | raise AssertionError("This lamp does not support the swith-off function") 37 | 38 | data = [{"attributes": {"isOn": lamp_on}}] 39 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 40 | self.attributes.is_on = lamp_on 41 | 42 | def set_light_level(self, light_level: int) -> None: 43 | if "lightLevel" not in self.capabilities.can_receive: 44 | raise AssertionError( 45 | "This lamp does not support the set lightLevel function" 46 | ) 47 | if light_level < 1 or light_level > 100: 48 | raise ValueError("light_level must be a value between 1 and 100") 49 | 50 | data = [{"attributes": {"lightLevel": light_level}}] 51 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 52 | self.attributes.light_level = light_level 53 | 54 | def set_color_temperature(self, color_temp: int) -> None: 55 | if "colorTemperature" not in self.capabilities.can_receive: 56 | raise AssertionError( 57 | "This lamp does not support the set colorTemperature function" 58 | ) 59 | if ( 60 | self.attributes.color_temperature_max is None 61 | or self.attributes.color_temperature_min is None 62 | ): 63 | raise ValueError("Values of color_temp_max or color_temp_min are None") 64 | if ( 65 | color_temp < self.attributes.color_temperature_max 66 | or color_temp > self.attributes.color_temperature_min 67 | ): 68 | raise ValueError( 69 | "color_temperature must be a value between " 70 | f"{self.attributes.color_temperature_max} and {self.attributes.color_temperature_min}" 71 | ) 72 | 73 | data = [{"attributes": {"colorTemperature": color_temp}}] 74 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 75 | self.attributes.color_temperature = color_temp 76 | 77 | def set_light_color(self, hue: float, saturation: float) -> None: 78 | if ( 79 | "colorHue" not in self.capabilities.can_receive 80 | or "colorSaturation" not in self.capabilities.can_receive 81 | ): 82 | raise AssertionError( 83 | "This lamp does not support the set light color function" 84 | ) 85 | if hue < 0 or hue > 360: 86 | raise ValueError("hue must be a value between 0 and 360") 87 | if saturation < 0.0 or saturation > 1.0: 88 | raise ValueError("saturation must be a value between 0.0 and 1.0") 89 | 90 | data = [{"attributes": {"colorHue": hue, "colorSaturation": saturation}}] 91 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 92 | self.attributes.color_hue = hue 93 | self.attributes.color_saturation = saturation 94 | 95 | def set_startup_behaviour(self, behaviour: StartupEnum) -> None: 96 | """ 97 | Sets the behaviour of the lamp in case of a power outage. 98 | When set to START_ON the lamp will turn on once the power is back. 99 | When set to START_OFF the lamp will stay off once the power is back. 100 | When set to START_PREVIOUS the lamp will resume its state at power outage. 101 | When set to START_TOGGLE, a sequence of power-off -> power-on, will toggle the lamp state 102 | """ 103 | data = [{"attributes": {"startupOnOff": behaviour.value}}] 104 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 105 | self.attributes.startup_on_off = behaviour 106 | 107 | 108 | def dict_to_light(data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub) -> Light: 109 | return Light( 110 | dirigeraClient=dirigera_client, 111 | **data, 112 | ) 113 | -------------------------------------------------------------------------------- /tests/test_blinds.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.blinds import dict_to_blind 5 | from src.dirigera.devices.blinds import Blind 6 | 7 | 8 | @pytest.fixture(name="fake_client") 9 | def fixture_fake_client() -> FakeDirigeraHub: 10 | return FakeDirigeraHub() 11 | 12 | 13 | @pytest.fixture(name="fake_blind") 14 | def fixture_blind(fake_client: FakeDirigeraHub) -> Blind: 15 | return Blind( 16 | dirigeraClient=fake_client, 17 | **{ 18 | "id": "1237-343-2dfa", 19 | "type": "blind", 20 | "deviceType": "blinds", 21 | "createdAt": "2023-01-07T20:07:19.000Z", 22 | "isReachable": True, 23 | "lastSeen": "2023-10-28T04:42:15.000Z", 24 | "customIcon": "lighting_nightstand_light", 25 | "attributes": { 26 | "customName": "Light 2", 27 | "model": "FYRTUR", 28 | "manufacturer": "IKEA of Sweden", 29 | "firmwareVersion": "2.3.093", 30 | "serialNumber": "84", 31 | "hardwareVersion": "2", 32 | "blindsTargetLevel": 15, 33 | "blindsCurrentLevel": 90, 34 | "blindsState": "down", 35 | "productCode": "LED2003G10", 36 | }, 37 | "capabilities": { 38 | "canSend": [], 39 | "canReceive": [ 40 | "customName", 41 | "blindsCurrentLevel", 42 | "blindsTargetLevel", 43 | "blindsState", 44 | ], 45 | }, 46 | "room": { 47 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 48 | "name": "Bedroom", 49 | "color": "ikea_yellow_no_24", 50 | "icon": "rooms_bed", 51 | }, 52 | "deviceSet": [], 53 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 54 | }, 55 | ) 56 | 57 | 58 | def test_set_name(fake_blind: Blind, fake_client: FakeDirigeraHub) -> None: 59 | new_name = "blindedbythelight" 60 | fake_blind.set_name(new_name) 61 | action = fake_client.patch_actions.pop() 62 | assert action["route"] == f"/devices/{fake_blind.id}" 63 | assert action["data"] == [{"attributes": {"customName": new_name}}] 64 | assert fake_blind.attributes.custom_name == new_name 65 | 66 | 67 | def test_set_target_level(fake_blind: Blind, fake_client: FakeDirigeraHub) -> None: 68 | target_level = 80 69 | fake_blind.set_target_level(target_level) 70 | action = fake_client.patch_actions.pop() 71 | assert action["route"] == f"/devices/{fake_blind.id}" 72 | assert action["data"] == [{"attributes": {"blindsTargetLevel": target_level}}] 73 | assert fake_blind.attributes.blinds_target_level == target_level 74 | assert fake_blind.attributes.blinds_current_level == 90 75 | 76 | 77 | def test_dict_to_blind(fake_client: FakeDirigeraHub) -> None: 78 | data: Dict[str, Any] = { 79 | "id": "1237-343-2dfa", 80 | "type": "blind", 81 | "deviceType": "blinds", 82 | "createdAt": "2023-01-07T20:07:19.000Z", 83 | "isReachable": True, 84 | "lastSeen": "2023-10-28T04:42:15.000Z", 85 | "customIcon": "lighting_nightstand_light", 86 | "attributes": { 87 | "customName": "Light 2", 88 | "model": "FYRTUR", 89 | "manufacturer": "IKEA of Sweden", 90 | "firmwareVersion": "2.3.093", 91 | "serialNumber": "84", 92 | "hardwareVersion": "2", 93 | "blindsTargetLevel": 15, 94 | "blindsCurrentLevel": 90, 95 | "blindsState": "down", 96 | "productCode": "LED2003G10", 97 | }, 98 | "capabilities": { 99 | "canSend": [], 100 | "canReceive": [ 101 | "customName", 102 | "blindsCurrentLevel", 103 | "blindsTargetLevel", 104 | "blindsState", 105 | ], 106 | }, 107 | "room": { 108 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 109 | "name": "Bedroom", 110 | "color": "ikea_yellow_no_24", 111 | "icon": "rooms_bed", 112 | }, 113 | "deviceSet": [], 114 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 115 | } 116 | 117 | blind = dict_to_blind(data, fake_client) 118 | assert blind.dirigera_client == fake_client 119 | assert blind.id == data["id"] 120 | assert blind.is_reachable == data["isReachable"] 121 | assert blind.attributes.custom_name == data["attributes"]["customName"] 122 | assert ( 123 | blind.attributes.blinds_target_level == data["attributes"]["blindsTargetLevel"] 124 | ) 125 | assert ( 126 | blind.attributes.blinds_current_level 127 | == data["attributes"]["blindsCurrentLevel"] 128 | ) 129 | assert blind.attributes.blinds_state == data["attributes"]["blindsState"] 130 | assert blind.capabilities.can_receive == data["capabilities"]["canReceive"] 131 | assert blind.room.id == data["room"]["id"] 132 | assert blind.room.name == data["room"]["name"] 133 | assert blind.attributes.firmware_version == data["attributes"]["firmwareVersion"] 134 | assert blind.attributes.hardware_version == data["attributes"]["hardwareVersion"] 135 | assert blind.attributes.model == data["attributes"]["model"] 136 | assert blind.attributes.manufacturer == data["attributes"]["manufacturer"] 137 | -------------------------------------------------------------------------------- /tests/test_scenes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Dict 3 | import pytest 4 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 5 | from src.dirigera.devices.scene import Scene, dict_to_scene, ControllerType 6 | 7 | TEST_ID = "c9bbf831-6dcd-4442-8195-53eedb66a598" 8 | TEST_NAME = "Tesscene" 9 | TEST_ICON = "scenes_clean_sparkles" 10 | TEST_LAST_COMPLETED = "2023-08-27T11:01:14.767Z" 11 | TEST_LAST_TRIGGERED = "2023-08-27T11:01:14.767Z" 12 | TEST_LAST_UNDO = "2023-08-27T10:29:33.049Z" 13 | 14 | 15 | @pytest.fixture(name="fake_client") 16 | def fixture_fake_client() -> FakeDirigeraHub: 17 | return FakeDirigeraHub() 18 | 19 | 20 | @pytest.fixture(name="fake_scene") 21 | def fixture_scene(fake_client: FakeDirigeraHub) -> Scene: 22 | return Scene( 23 | dirigeraClient=fake_client, 24 | **{ 25 | "id": "c9bbf831-6dcd-4442-8195-53eedb66a598", 26 | "info": {"name": "Tesscene", "icon": "scenes_clean_sparkles"}, 27 | "type": "userScene", 28 | "triggers": [ 29 | { 30 | "id": "f3ad4585-1a73-4e9c-9329-f926bedf509c", 31 | "type": "app", 32 | "triggeredAt": "2023-08-27T11:01:14.747Z", 33 | "disabled": False, 34 | } 35 | ], 36 | "actions": [ 37 | { 38 | "id": "d0bf2ebf-3fcf-4e68-9810-0dc552e40388", 39 | "type": "deviceSet", 40 | "attributes": {"isOn": True, "lightLevel": 100}, 41 | } 42 | ], 43 | "commands": [], 44 | "createdAt": "2023-08-27T10:28:48.096Z", 45 | "lastCompleted": "2023-08-27T11:01:14.767Z", 46 | "lastTriggered": "2023-08-27T11:01:14.767Z", 47 | "undoAllowedDuration": 30, 48 | "lastUndo": "2023-08-27T10:29:33.049Z", 49 | }, 50 | ) 51 | 52 | 53 | def test_trigger(fake_scene: Scene, fake_client: FakeDirigeraHub) -> None: 54 | fake_scene.trigger() 55 | action = fake_client.post_actions.pop() 56 | assert action["route"] == f"/scenes/{fake_scene.id}/trigger" 57 | 58 | 59 | def test_dict_to_scene(fake_client: FakeDirigeraHub) -> None: 60 | data1: Dict[str, Any] = { 61 | "id": "c9bbf831-6dcd-4442-8195-53eedb66a598", 62 | "info": {"name": "Tesscene", "icon": "scenes_clean_sparkles"}, 63 | "type": "userScene", 64 | "triggers": [ 65 | { 66 | "id": "f3ad4585-1a73-4e9c-9329-f926bedf509c", 67 | "type": "app", 68 | "triggeredAt": "2023-08-27T11:01:14.747Z", 69 | "disabled": False, 70 | } 71 | ], 72 | "actions": [ 73 | { 74 | "id": "d0bf2ebf-3fcf-4e68-9810-0dc552e40388", 75 | "type": "deviceSet", 76 | "attributes": {"isOn": True, "lightLevel": 100}, 77 | } 78 | ], 79 | "commands": [], 80 | "createdAt": "2023-08-27T10:28:48.096Z", 81 | "lastCompleted": "2023-08-27T11:01:14.767Z", 82 | "lastTriggered": "2023-08-27T11:01:14.767Z", 83 | "undoAllowedDuration": 30, 84 | "lastUndo": "2023-08-27T10:29:33.049Z", 85 | } 86 | 87 | scene1 = dict_to_scene(data1, fake_client) 88 | assert scene1.id == TEST_ID 89 | assert scene1.info.name == TEST_NAME 90 | assert scene1.info.icon.value == TEST_ICON 91 | assert scene1.last_completed == datetime.datetime.strptime( 92 | TEST_LAST_COMPLETED, "%Y-%m-%dT%H:%M:%S.%f%z" 93 | ) 94 | 95 | data2: Dict[str, Any] = { 96 | "id": "00a0a00a-0aa0-0000-a000-00a0000a0a00", 97 | "info": { 98 | "name": "Night", 99 | "icon": "scenes_clean_sparkles" 100 | }, 101 | "type": "userScene", 102 | "triggers": [ 103 | { 104 | "id": "0000a0a0-0a00-000a-0a00-aaaaaa000000", 105 | "type": "app", 106 | "triggeredAt": "2024-04-23T21:34:51.619Z", 107 | "disabled": False 108 | }, 109 | { 110 | "id": "a0000000-a000-0000-aaaa-0aaa000aa000", 111 | "type": "controller", 112 | "triggeredAt": "2024-04-20T05:49:20.178Z", 113 | "disabled": False, 114 | "trigger": { 115 | "days": [ 116 | "Mon", 117 | "Tue", 118 | "Wed", 119 | "Thu", 120 | "Fri", 121 | "Sat", 122 | "Sun" 123 | ], 124 | "controllerType": "shortcutController", 125 | "clickPattern": "doublePress", 126 | "buttonIndex": 0, 127 | "deviceId": "0000aaaa-0000-0000-aa00-0a0aa0a000a0_2" 128 | } 129 | }, 130 | { 131 | "id": "a0000000-a000-0000-aaaa-0aaa000aa000", 132 | "type": "sunriseSunset", 133 | "triggeredAt": "2024-04-24T17:49:00.989Z", 134 | "disabled": False, 135 | "trigger": { 136 | "days": [ 137 | "Tue", 138 | "Wed", 139 | "Sun", 140 | "Mon", 141 | "Fri", 142 | "Sat", 143 | "Thu" 144 | ], 145 | "type": "sunset", 146 | "offset": 0 147 | }, 148 | "nextTriggerAt": "2024-04-25T17:49:00.000Z" 149 | } 150 | ], 151 | "actions": [], 152 | "commands": [], 153 | "createdAt": "2023-11-06T22:10:14.806Z", 154 | "lastCompleted": "2024-04-24T05:13:24.352Z", 155 | "lastTriggered": "2024-04-24T05:13:24.352Z", 156 | "undoAllowedDuration": 30 157 | } 158 | 159 | scene2 = dict_to_scene(data2, fake_client) 160 | assert scene2.triggers[1].trigger is not None 161 | assert scene2.triggers[1].trigger.controllerType == ControllerType.SHORTCUT_CONTROLLER 162 | -------------------------------------------------------------------------------- /tests/test_outlet.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.outlet import dict_to_outlet 5 | from src.dirigera.devices.outlet import Outlet 6 | from src.dirigera.devices.device import StartupEnum 7 | 8 | 9 | @pytest.fixture(name="fake_client") 10 | def fixture_fake_client() -> FakeDirigeraHub: 11 | return FakeDirigeraHub() 12 | 13 | 14 | @pytest.fixture(name="fake_outlet_dict") 15 | def fixture_outlet_dict() -> Dict: 16 | return { 17 | "id": "f430fd01", 18 | "type": "outlet", 19 | "deviceType": "outlet", 20 | "isReachable": True, 21 | "lastSeen": "2023-01-07T20:07:19.000Z", 22 | "createdAt": "2023-01-07T20:07:19.000Z", 23 | "attributes": { 24 | "customName": "coffee", 25 | "model": "TRADFRI control outlet", 26 | "manufacturer": "IKEA of Sweden", 27 | "firmwareVersion": "2.3.089", 28 | "hardwareVersion": "1", 29 | "serialNumber": "1", 30 | "productCode": "E1603", 31 | "isOn": True, 32 | "startupOnOff": "startPrevious", 33 | "lightLevel": 100, 34 | "startUpCurrentLevel": -1, 35 | "identifyStarted": "2000-01-01T00:00:00.000Z", 36 | "identifyPeriod": 0, 37 | "permittingJoin": False, 38 | "otaStatus": "upToDate", 39 | "otaState": "readyToCheck", 40 | "otaProgress": 0, 41 | "otaPolicy": "autoUpdate", 42 | "otaScheduleStart": "00:00", 43 | "otaScheduleEnd": "00:00", 44 | }, 45 | "capabilities": { 46 | "canSend": [], 47 | "canReceive": ["customName", "isOn", "lightLevel"], 48 | }, 49 | "room": {"id": "63ffdf20", "name": "kitchen", "color": "color", "icon": "icon"}, 50 | "deviceSet": [], 51 | "remoteLinks": ["152461d3"], 52 | "isHidden": False, 53 | } 54 | 55 | 56 | @pytest.fixture(name="fake_outlet") 57 | def fixture_outlet(fake_outlet_dict: Dict, fake_client: FakeDirigeraHub) -> Outlet: 58 | return Outlet(dirigeraClient=fake_client, **fake_outlet_dict) 59 | 60 | 61 | def test_set_name(fake_outlet: Outlet, fake_client: FakeDirigeraHub) -> None: 62 | new_name = "teapot" 63 | fake_outlet.set_name(new_name) 64 | action = fake_client.patch_actions.pop() 65 | assert action["route"] == f"/devices/{fake_outlet.id}" 66 | assert action["data"] == [{"attributes": {"customName": new_name}}] 67 | assert fake_outlet.attributes.custom_name == new_name 68 | 69 | 70 | def test_set_outlet_on(fake_outlet: Outlet, fake_client: FakeDirigeraHub) -> None: 71 | fake_outlet.set_on(True) 72 | action = fake_client.patch_actions.pop() 73 | assert action["route"] == f"/devices/{fake_outlet.id}" 74 | assert action["data"] == [{"attributes": {"isOn": True}}] 75 | assert fake_outlet.attributes.is_on 76 | 77 | 78 | def test_set_outlet_off(fake_outlet: Outlet, fake_client: FakeDirigeraHub) -> None: 79 | fake_outlet.set_on(False) 80 | action = fake_client.patch_actions.pop() 81 | assert action["route"] == f"/devices/{fake_outlet.id}" 82 | assert action["data"] == [{"attributes": {"isOn": False}}] 83 | assert not fake_outlet.attributes.is_on 84 | 85 | 86 | def test_set_startup_behaviour_off( 87 | fake_outlet: Outlet, fake_client: FakeDirigeraHub 88 | ) -> None: 89 | behaviour = StartupEnum.START_OFF 90 | fake_outlet.set_startup_behaviour(behaviour) 91 | action = fake_client.patch_actions.pop() 92 | assert action["route"] == f"/devices/{fake_outlet.id}" 93 | assert action["data"] == [{"attributes": {"startupOnOff": behaviour.value}}] 94 | assert fake_outlet.attributes.startup_on_off == behaviour 95 | 96 | 97 | def test_dict_to_outlet(fake_client: FakeDirigeraHub) -> None: 98 | data: Dict[str, Any] = { 99 | "id": "f430fd01", 100 | "type": "outlet", 101 | "deviceType": "outlet", 102 | "isReachable": True, 103 | "lastSeen": "2023-01-07T20:07:19.000Z", 104 | "createdAt": "2023-01-07T20:07:19.000Z", 105 | "attributes": { 106 | "customName": "coffee", 107 | "model": "TRADFRI control outlet", 108 | "manufacturer": "IKEA of Sweden", 109 | "firmwareVersion": "2.3.089", 110 | "hardwareVersion": "1", 111 | "serialNumber": "1", 112 | "productCode": "E1603", 113 | "isOn": True, 114 | "startupOnOff": "startPrevious", 115 | "lightLevel": 100, 116 | "startUpCurrentLevel": -1, 117 | "identifyStarted": "2000-01-01T00:00:00.000Z", 118 | "identifyPeriod": 0, 119 | "permittingJoin": False, 120 | "otaStatus": "upToDate", 121 | "otaState": "readyToCheck", 122 | "otaProgress": 0, 123 | "otaPolicy": "autoUpdate", 124 | "otaScheduleStart": "00:00", 125 | "otaScheduleEnd": "00:00", 126 | }, 127 | "capabilities": { 128 | "canSend": [], 129 | "canReceive": ["customName", "isOn", "lightLevel"], 130 | }, 131 | "room": {"id": "63ffdf20", "name": "kitchen", "color": "color", "icon": "icon"}, 132 | "deviceSet": [], 133 | "remoteLinks": ["152461d3"], 134 | "isHidden": False, 135 | } 136 | 137 | outlet = dict_to_outlet(data, fake_client) 138 | assert outlet.dirigera_client == fake_client 139 | assert outlet.id == data["id"] 140 | assert outlet.is_reachable == data["isReachable"] 141 | assert outlet.attributes.custom_name == data["attributes"]["customName"] 142 | assert outlet.attributes.is_on == data["attributes"]["isOn"] 143 | assert outlet.attributes.startup_on_off == StartupEnum( 144 | data["attributes"]["startupOnOff"] 145 | ) 146 | assert outlet.capabilities.can_receive == data["capabilities"]["canReceive"] 147 | assert outlet.room.id == data["room"]["id"] 148 | assert outlet.room.name == data["room"]["name"] 149 | assert outlet.attributes.firmware_version == data["attributes"]["firmwareVersion"] 150 | assert outlet.attributes.hardware_version == data["attributes"]["hardwareVersion"] 151 | assert outlet.attributes.model == data["attributes"]["model"] 152 | assert outlet.attributes.manufacturer == data["attributes"]["manufacturer"] 153 | assert outlet.attributes.serial_number == data["attributes"]["serialNumber"] 154 | -------------------------------------------------------------------------------- /tests/test_motion_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.motion_sensor import MotionSensor, dict_to_motion_sensor 5 | 6 | 7 | @pytest.fixture(name="fake_client") 8 | def fixture_fake_client() -> FakeDirigeraHub: 9 | return FakeDirigeraHub() 10 | 11 | 12 | @pytest.fixture(name="motion_sensor_dict_non_ikea") 13 | def fixture_motion_sensor_dict_non_ikea() -> Dict: 14 | return { 15 | "id": "62e95143-c8b6-4f28-b581-adfd622c0db7_1", 16 | "type": "sensor", 17 | "deviceType": "motionSensor", 18 | "createdAt": "2023-12-14T18:28:57.000Z", 19 | "isReachable": True, 20 | "lastSeen": "2023-12-14T17:30:48.000Z", 21 | "attributes": { 22 | "customName": "Bewegungssensor", 23 | "firmwareVersion": "", 24 | "hardwareVersion": "", 25 | "manufacturer": "SONOFF", 26 | "model": "Wireless Motion Sensor", 27 | "productCode": "SNZB-03", 28 | "serialNumber": "9", 29 | "isOn": False, 30 | "permittingJoin": False, 31 | "sensorConfig": { 32 | "scheduleOn": False, 33 | "onDuration": 120, 34 | "schedule": { 35 | "onCondition": {"time": "sunset", "offset": -60}, 36 | "offCondition": {"time": "sunrise", "offset": 60}, 37 | }, 38 | }, 39 | "circadianPresets": [], 40 | }, 41 | "capabilities": { 42 | "canSend": ["isOn", "lightLevel"], 43 | "canReceive": ["customName"], 44 | }, 45 | "room": { 46 | "id": "e1631a64-9ceb-4113-a6b3-1d866216503c", 47 | "name": "Zimmer", 48 | "color": "ikea_beige_1", 49 | "icon": "rooms_arm_chair", 50 | }, 51 | "deviceSet": [], 52 | "remoteLinks": [], 53 | "isHidden": False, 54 | } 55 | 56 | 57 | @pytest.fixture(name="motion_sensor_dict") 58 | def fixture_motion_sensor_dict() -> Dict: 59 | return { 60 | "id": "62e95143-c8b6-4f28-b581-adfd622c0db7_1", 61 | "type": "sensor", 62 | "deviceType": "motionSensor", 63 | "createdAt": "2023-12-14T18:28:57.000Z", 64 | "isReachable": True, 65 | "lastSeen": "2023-12-14T17:30:48.000Z", 66 | "attributes": { 67 | "customName": "Bewegungssensor", 68 | "firmwareVersion": "24.4.5", 69 | "hardwareVersion": "1", 70 | "manufacturer": "IKEA of Sweden", 71 | "model": "TRADFRI motion sensor", 72 | "productCode": "E1745", 73 | "serialNumber": "142D51FFFE229101", 74 | "batteryPercentage": 100, 75 | "isOn": False, 76 | "lightLevel": 1, 77 | "permittingJoin": False, 78 | "otaPolicy": "autoUpdate", 79 | "otaProgress": 0, 80 | "otaScheduleEnd": "00:00", 81 | "otaScheduleStart": "00:00", 82 | "otaState": "readyToCheck", 83 | "otaStatus": "upToDate", 84 | "sensorConfig": { 85 | "scheduleOn": False, 86 | "onDuration": 120, 87 | "schedule": { 88 | "onCondition": {"time": "sunset", "offset": -60}, 89 | "offCondition": {"time": "sunrise", "offset": 60}, 90 | }, 91 | }, 92 | "circadianPresets": [], 93 | }, 94 | "capabilities": { 95 | "canSend": ["isOn", "lightLevel"], 96 | "canReceive": ["customName"], 97 | }, 98 | "room": { 99 | "id": "e1631a64-9ceb-4113-a6b3-1d866216503c", 100 | "name": "Zimmer", 101 | "color": "ikea_beige_1", 102 | "icon": "rooms_arm_chair", 103 | }, 104 | "deviceSet": [], 105 | "remoteLinks": [], 106 | "isHidden": False, 107 | } 108 | 109 | 110 | @pytest.fixture(name="fake_motion_sensor") 111 | def fixture_blind( 112 | motion_sensor_dict: Dict, fake_client: FakeDirigeraHub 113 | ) -> MotionSensor: 114 | return MotionSensor( 115 | dirigeraClient=fake_client, 116 | **motion_sensor_dict, 117 | ) 118 | 119 | 120 | def test_set_motion_sensor_name( 121 | fake_motion_sensor: MotionSensor, fake_client: FakeDirigeraHub 122 | ) -> None: 123 | new_name = "motion_sensor_name" 124 | assert fake_motion_sensor.attributes.custom_name != new_name 125 | fake_motion_sensor.set_name(new_name) 126 | action = fake_client.patch_actions.pop() 127 | assert action["route"] == f"/devices/{fake_motion_sensor.id}" 128 | assert action["data"] == [{"attributes": {"customName": new_name}}] 129 | assert fake_motion_sensor.attributes.custom_name == new_name 130 | 131 | 132 | def test_dict_to_motion_sensor(motion_sensor_dict: Dict, fake_client: FakeDirigeraHub) -> None: 133 | motion_sensor = dict_to_motion_sensor(motion_sensor_dict, fake_client) 134 | assert motion_sensor.dirigera_client == fake_client 135 | assert motion_sensor.id == motion_sensor_dict["id"] 136 | assert motion_sensor.is_reachable == motion_sensor_dict["isReachable"] 137 | assert ( 138 | motion_sensor.attributes.custom_name 139 | == motion_sensor_dict["attributes"]["customName"] 140 | ) 141 | assert ( 142 | motion_sensor.attributes.battery_percentage 143 | == motion_sensor_dict["attributes"]["batteryPercentage"] 144 | ) 145 | assert motion_sensor.attributes.is_on == motion_sensor_dict["attributes"]["isOn"] 146 | assert ( 147 | motion_sensor.attributes.light_level 148 | == motion_sensor_dict["attributes"]["lightLevel"] 149 | ) 150 | assert ( 151 | motion_sensor.capabilities.can_receive 152 | == motion_sensor_dict["capabilities"]["canReceive"] 153 | ) 154 | assert motion_sensor.room.id == motion_sensor_dict["room"]["id"] 155 | assert motion_sensor.room.name == motion_sensor_dict["room"]["name"] 156 | assert ( 157 | motion_sensor.attributes.firmware_version 158 | == motion_sensor_dict["attributes"]["firmwareVersion"] 159 | ) 160 | assert ( 161 | motion_sensor.attributes.hardware_version 162 | == motion_sensor_dict["attributes"]["hardwareVersion"] 163 | ) 164 | assert motion_sensor.attributes.model == motion_sensor_dict["attributes"]["model"] 165 | assert ( 166 | motion_sensor.attributes.manufacturer 167 | == motion_sensor_dict["attributes"]["manufacturer"] 168 | ) 169 | 170 | def test_dict_to_motion_sensor_optional_fields(motion_sensor_dict_non_ikea: Dict, fake_client: FakeDirigeraHub) -> None: 171 | motion_sensor = dict_to_motion_sensor(motion_sensor_dict_non_ikea, fake_client) 172 | 173 | assert motion_sensor.attributes.battery_percentage is None 174 | -------------------------------------------------------------------------------- /tests/test_air_purifier.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.air_purifier import ( 5 | AirPurifier, 6 | FanModeEnum, 7 | dict_to_air_purifier 8 | ) 9 | 10 | 11 | @pytest.fixture(name="fake_client") 12 | def fixture_fake_client() -> FakeDirigeraHub: 13 | return FakeDirigeraHub() 14 | 15 | @pytest.fixture(name="purifier_dict") 16 | def fixture_fake_air_purifier_dict() -> dict: 17 | return { 18 | "id": "d121f38a-fc37-4bd9-8a3c-f79e4f45fccf_1", 19 | "type": "airPurifier", 20 | "deviceType": "airPurifier", 21 | "createdAt": "2023-08-09T12:31:59.000Z", 22 | "isReachable": True, 23 | "lastSeen": "2024-02-21T19:55:44.000Z", 24 | "attributes": { 25 | "customName": "Air Purifier", 26 | "firmwareVersion": "1.0.033", 27 | "hardwareVersion": "1", 28 | "manufacturer": "IKEA of Sweden", 29 | "model": "STARKVIND Air purifier", 30 | "productCode": "E2007", 31 | "serialNumber": "2C1165FFFE89F47C", 32 | "fanMode": "auto", 33 | "fanModeSequence": "lowMediumHighAuto", 34 | "motorRuntime": 106570, 35 | "motorState": 15, 36 | "filterAlarmStatus": False, 37 | "filterElapsedTime": 227980, 38 | "filterLifetime": 259200, 39 | "childLock": False, 40 | "statusLight": True, 41 | "currentPM25": 3, 42 | "identifyPeriod": 0, 43 | "identifyStarted": "2000-01-01T00:00:00.000Z", 44 | "permittingJoin": False, 45 | "otaPolicy": "autoUpdate", 46 | "otaProgress": 0, 47 | "otaScheduleEnd": "00:00", 48 | "otaScheduleStart": "00:00", 49 | "otaState": "readyToCheck", 50 | "otaStatus": "updateAvailable", 51 | }, 52 | "capabilities": { 53 | "canSend": [], 54 | "canReceive": [ 55 | "customName", 56 | "fanMode", 57 | "fanModeSequence", 58 | "motorState", 59 | "childLock", 60 | "statusLight", 61 | ], 62 | }, 63 | "room": { 64 | "id": "1a846fdc-317c-4d94-8722-cb0196256a16", 65 | "name": "Livingroom", 66 | "color": "ikea_green_no_66", 67 | "icon": "rooms_arm_chair", 68 | }, 69 | "deviceSet": [], 70 | "remoteLinks": [], 71 | "isHidden": False, 72 | } 73 | 74 | 75 | @pytest.fixture(name="fake_purifier") 76 | def fixture_purifier( 77 | fake_client: FakeDirigeraHub, purifier_dict: Dict 78 | ) -> AirPurifier: 79 | return AirPurifier(dirigeraClient=fake_client, **purifier_dict) 80 | 81 | def test_set_name(fake_purifier: AirPurifier, fake_client: FakeDirigeraHub) -> None: 82 | new_name = "Luftreiniger" 83 | fake_purifier.set_name(new_name) 84 | action = fake_client.patch_actions.pop() 85 | assert action["route"] == f"/devices/{fake_purifier.id}" 86 | assert action["data"] == [{"attributes": {"customName": new_name}}] 87 | assert fake_purifier.attributes.custom_name == new_name 88 | 89 | def test_set_fan_mode_enum(fake_purifier: AirPurifier, fake_client: FakeDirigeraHub) -> None: 90 | new_mode = FanModeEnum.LOW 91 | fake_purifier.set_fan_mode(new_mode) 92 | action = fake_client.patch_actions.pop() 93 | assert action["route"] == f"/devices/{fake_purifier.id}" 94 | assert action["data"] == [{"attributes": {"fanMode": new_mode.value}}] 95 | assert fake_purifier.attributes.fan_mode == new_mode 96 | 97 | def test_set_motor_state(fake_purifier: AirPurifier, fake_client: FakeDirigeraHub) -> None: 98 | new_motor_state = 42 99 | fake_purifier.set_motor_state(new_motor_state) 100 | action = fake_client.patch_actions.pop() 101 | assert action["route"] == f"/devices/{fake_purifier.id}" 102 | assert action["data"] == [{"attributes": {"motorState": new_motor_state}}] 103 | assert fake_purifier.attributes.motor_state == new_motor_state 104 | 105 | def test_set_child_lock(fake_purifier: AirPurifier, fake_client: FakeDirigeraHub) -> None: 106 | new_child_lock = True 107 | fake_purifier.set_child_lock(new_child_lock) 108 | action = fake_client.patch_actions.pop() 109 | assert action["route"] == f"/devices/{fake_purifier.id}" 110 | assert action["data"] == [{"attributes": {"childLock": new_child_lock}}] 111 | assert fake_purifier.attributes.child_lock == new_child_lock 112 | 113 | def test_status_light(fake_purifier: AirPurifier, fake_client: FakeDirigeraHub) -> None: 114 | new_status_light = False 115 | fake_purifier.set_status_light(new_status_light) 116 | action = fake_client.patch_actions.pop() 117 | assert action["route"] == f"/devices/{fake_purifier.id}" 118 | assert action["data"] == [{"attributes": {"statusLight": new_status_light}}] 119 | assert fake_purifier.attributes.status_light == new_status_light 120 | 121 | 122 | def test_dict_to_purifier(fake_client: FakeDirigeraHub, purifier_dict: Dict) -> None: 123 | purifier = dict_to_air_purifier(purifier_dict, fake_client) 124 | assert purifier.id == purifier_dict["id"] 125 | assert purifier.is_reachable == purifier_dict["isReachable"] 126 | assert purifier.attributes.custom_name == purifier_dict["attributes"]["customName"] 127 | assert ( 128 | purifier.attributes.firmware_version 129 | == purifier_dict["attributes"]["firmwareVersion"] 130 | ) 131 | assert ( 132 | purifier.attributes.hardware_version 133 | == purifier_dict["attributes"]["hardwareVersion"] 134 | ) 135 | assert purifier.attributes.model == purifier_dict["attributes"]["model"] 136 | assert purifier.attributes.serial_number == purifier_dict["attributes"]["serialNumber"] 137 | assert purifier.attributes.manufacturer == purifier_dict["attributes"]["manufacturer"] 138 | assert purifier.attributes.fan_mode.value == purifier_dict["attributes"]["fanMode"] 139 | assert purifier.attributes.fan_mode_sequence == purifier_dict["attributes"]["fanModeSequence"] 140 | assert purifier.attributes.motor_state == purifier_dict["attributes"]["motorState"] 141 | assert purifier.attributes.child_lock == purifier_dict["attributes"]["childLock"] 142 | assert purifier.attributes.status_light == purifier_dict["attributes"]["statusLight"] 143 | assert purifier.attributes.motor_runtime == purifier_dict["attributes"]["motorRuntime"] 144 | assert purifier.attributes.filter_alarm_status == purifier_dict["attributes"]["filterAlarmStatus"] 145 | assert purifier.attributes.filter_elapsed_time == purifier_dict["attributes"]["filterElapsedTime"] 146 | assert purifier.attributes.filter_lifetime == purifier_dict["attributes"]["filterLifetime"] 147 | assert purifier.attributes.current_p_m25 == purifier_dict["attributes"]["currentPM25"] 148 | assert purifier.capabilities.can_receive == purifier_dict["capabilities"]["canReceive"] 149 | assert purifier.room.id == purifier_dict["room"]["id"] 150 | assert purifier.room.name == purifier_dict["room"]["name"] 151 | -------------------------------------------------------------------------------- /tests/test_controller.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | import pytest 3 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 4 | from src.dirigera.devices.controller import dict_to_controller 5 | from src.dirigera.devices.controller import Controller 6 | 7 | 8 | @pytest.fixture(name="fake_client") 9 | def fixture_fake_client() -> FakeDirigeraHub: 10 | return FakeDirigeraHub() 11 | 12 | 13 | @pytest.fixture(name="fake_controller") 14 | def fixture_controller(fake_client: FakeDirigeraHub) -> Controller: 15 | return Controller( 16 | dirigeraClient=fake_client, 17 | **{ 18 | "id": "1237-343-2dfa", 19 | "type": "controller", 20 | "deviceType": "lightController", 21 | "createdAt": "2023-01-07T20:07:19.000Z", 22 | "isReachable": True, 23 | "lastSeen": "2023-10-28T04:42:15.000Z", 24 | "customIcon": "lighting_nightstand_light", 25 | "attributes": { 26 | "customName": "Remote", 27 | "model": "TRADFRI STYRBAR", 28 | "manufacturer": "IKEA of Sweden", 29 | "firmwareVersion": "2.3.093", 30 | "hardwareVersion": "2", 31 | "isOn": False, 32 | "batteryPercentage": 90, 33 | }, 34 | "capabilities": { 35 | "canSend": ["isOn", "lightLevel"], 36 | "canReceive": [ 37 | "customName", 38 | ], 39 | }, 40 | "room": { 41 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 42 | "name": "Bedroom", 43 | "color": "ikea_yellow_no_24", 44 | "icon": "rooms_bed", 45 | }, 46 | "deviceSet": [], 47 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 48 | }, 49 | ) 50 | 51 | 52 | def test_set_name(fake_controller: Controller, fake_client: FakeDirigeraHub) -> None: 53 | new_name = "outofcontrol" 54 | fake_controller.set_name(new_name) 55 | action = fake_client.patch_actions.pop() 56 | assert action["route"] == f"/devices/{fake_controller.id}" 57 | assert action["data"] == [{"attributes": {"customName": new_name}}] 58 | assert fake_controller.attributes.custom_name == new_name 59 | 60 | 61 | def test_dict_to_controller(fake_client: FakeDirigeraHub) -> None: 62 | data: Dict[str, Any] = { 63 | "id": "1237-343-2dfa", 64 | "type": "controller", 65 | "deviceType": "lightController", 66 | "createdAt": "2023-01-07T20:07:19.000Z", 67 | "isReachable": True, 68 | "lastSeen": "2023-10-28T04:42:15.000Z", 69 | "customIcon": "lighting_nightstand_light", 70 | "attributes": { 71 | "customName": "Remote", 72 | "model": "TRADFRI STYRBAR", 73 | "manufacturer": "IKEA of Sweden", 74 | "firmwareVersion": "2.3.093", 75 | "hardwareVersion": "2", 76 | "isOn": False, 77 | "batteryPercentage": 90, 78 | }, 79 | "capabilities": { 80 | "canSend": ["isOn", "lightLevel"], 81 | "canReceive": [ 82 | "customName", 83 | ], 84 | }, 85 | "room": { 86 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 87 | "name": "Bedroom", 88 | "color": "ikea_yellow_no_24", 89 | "icon": "rooms_bed", 90 | }, 91 | "deviceSet": [], 92 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 93 | } 94 | 95 | controller = dict_to_controller(data, fake_client) 96 | assert controller.dirigera_client == fake_client 97 | assert controller.id == data["id"] 98 | assert controller.is_reachable == data["isReachable"] 99 | assert controller.attributes.custom_name == data["attributes"]["customName"] 100 | assert controller.attributes.is_on == data["attributes"]["isOn"] 101 | assert ( 102 | controller.attributes.battery_percentage 103 | == data["attributes"]["batteryPercentage"] 104 | ) 105 | assert controller.capabilities.can_receive == data["capabilities"]["canReceive"] 106 | assert controller.room.id == data["room"]["id"] 107 | assert controller.room.name == data["room"]["name"] 108 | assert ( 109 | controller.attributes.firmware_version == data["attributes"]["firmwareVersion"] 110 | ) 111 | assert ( 112 | controller.attributes.hardware_version == data["attributes"]["hardwareVersion"] 113 | ) 114 | assert controller.attributes.model == data["attributes"]["model"] 115 | assert controller.attributes.manufacturer == data["attributes"]["manufacturer"] 116 | 117 | somrig_button_1: Dict[str, Any] = { 118 | "id": "1111aaaa-1111-1111-aa11-1a1aa1a111a1_1", 119 | "relationId": "1111aaaa-1111-1111-aa11-1a1aa1a111a1", 120 | "type": "controller", 121 | "deviceType": "shortcutController", 122 | "createdAt": "2024-04-12T20:50:26.000Z", 123 | "isReachable": True, 124 | "lastSeen": "2024-04-12T20:50:32.000Z", 125 | "attributes": { 126 | "customName": "Living room button", 127 | "model": "SOMRIG shortcut button", 128 | "manufacturer": "IKEA of Sweden", 129 | "firmwareVersion": "1.0.21", 130 | "hardwareVersion": "1", 131 | "serialNumber": "1AA1A1AAAA11A1AA", 132 | "productCode": "E2213", 133 | "batteryPercentage": 85, 134 | "switchLabel": "Shortcut 1", 135 | "isOn": False, 136 | "lightLevel": 1, 137 | "permittingJoin": False, 138 | "otaStatus": "upToDate", 139 | "otaState": "readyToCheck", 140 | "otaProgress": 0, 141 | "otaPolicy": "autoUpdate", 142 | "otaScheduleStart": "00:00", 143 | "otaScheduleEnd": "00:00" 144 | }, 145 | "capabilities": { 146 | "canSend": [ 147 | "singlePress", 148 | "longPress", 149 | "doublePress" 150 | ], 151 | "canReceive": [ 152 | "customName" 153 | ] 154 | }, 155 | "room": { 156 | "id": "1aa1a11a-1a1a-111a-1aaa-111a111a111a", 157 | "name": "Living room", 158 | "color": "ikea_green_no_65", 159 | "icon": "rooms_sofa" 160 | }, 161 | "deviceSet": [], 162 | "remoteLinks": [], 163 | "isHidden": False 164 | } 165 | 166 | controller = dict_to_controller(somrig_button_1, fake_client) 167 | assert controller.relation_id == somrig_button_1["relationId"] 168 | assert ( 169 | controller.attributes.switch_label 170 | == somrig_button_1["attributes"]["switchLabel"] 171 | ) 172 | 173 | somrig_button_2: Dict[str, Any] = { 174 | "id": "1111aaaa-1111-1111-aa11-1a1aa1a111a1_2", 175 | "relationId": "1111aaaa-1111-1111-aa11-1a1aa1a111a1", 176 | "type": "controller", 177 | "deviceType": "shortcutController", 178 | "createdAt": "2024-04-12T20:50:26.000Z", 179 | "isReachable": True, 180 | "lastSeen": "2024-04-12T20:50:32.000Z", 181 | "attributes": { 182 | "customName": "", 183 | "model": "SOMRIG shortcut button", 184 | "manufacturer": "IKEA of Sweden", 185 | "firmwareVersion": "1.0.21", 186 | "hardwareVersion": "1", 187 | "serialNumber": "1AA1A1AAAA11A1AA", 188 | "productCode": "E2213", 189 | "switchLabel": "Shortcut 2", 190 | "isOn": False, 191 | "lightLevel": 1, 192 | "permittingJoin": False, 193 | "otaStatus": "upToDate", 194 | "otaState": "readyToCheck", 195 | "otaProgress": 0, 196 | "otaPolicy": "autoUpdate", 197 | "otaScheduleStart": "00:00", 198 | "otaScheduleEnd": "00:00" 199 | }, 200 | "capabilities": { 201 | "canSend": [ 202 | "singlePress", 203 | "longPress", 204 | "doublePress" 205 | ], 206 | "canReceive": [ 207 | "customName" 208 | ] 209 | }, 210 | "deviceSet": [], 211 | "remoteLinks": [], 212 | "isHidden": False 213 | } 214 | 215 | controller = dict_to_controller(somrig_button_2, fake_client) 216 | assert controller.relation_id == somrig_button_2["relationId"] 217 | assert ( 218 | controller.attributes.switch_label 219 | == somrig_button_2["attributes"]["switchLabel"] 220 | ) 221 | -------------------------------------------------------------------------------- /tests/test_light.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict 3 | import pytest 4 | from src.dirigera.hub.abstract_smart_home_hub import FakeDirigeraHub 5 | from src.dirigera.devices.light import dict_to_light 6 | from src.dirigera.devices.light import Light 7 | from src.dirigera.devices.device import StartupEnum 8 | 9 | 10 | @pytest.fixture(name="fake_client") 11 | def fixture_fake_client() -> FakeDirigeraHub: 12 | return FakeDirigeraHub() 13 | 14 | 15 | @pytest.fixture(name="fake_light") 16 | def fixture_light(fake_client: FakeDirigeraHub) -> Light: 17 | data = """{ "id": "23taswdg-sdf-4eeb-99c2-23asdf2gw", 18 | "type": "light", 19 | "deviceType": "light", 20 | "createdAt": "2023-01-07T20:07:19.000Z", 21 | "isReachable": true, 22 | "lastSeen": "2023-10-28T04:42:14.000Z", 23 | "customIcon": "lighting_nightstand_light", 24 | "attributes": { 25 | "customName": "Bed", 26 | "model": "TRADFRIbulbE27WSglobeopal1055lm", 27 | "manufacturer": "IKEA of Sweden", 28 | "firmwareVersion": "1.0.012", 29 | "hardwareVersion": "1", 30 | "serialNumber": "04CD15FFFEC08659", 31 | "productCode": "LED2003G10", 32 | "isOn": false, 33 | "startupOnOff": "startOff", 34 | "lightLevel": 43, 35 | "colorTemperature": 2222, 36 | "colorTemperatureMin": 4000, 37 | "colorTemperatureMax": 2202, 38 | "startupTemperature": -1, 39 | "colorHue": 200, 40 | "colorSaturation": 0.7, 41 | "colorMode": "temperature", 42 | "identifyStarted": "2000-01-01T00:00:00.000Z", 43 | "identifyPeriod": 0, 44 | "permittingJoin": false, 45 | "otaStatus": "upToDate", 46 | "otaState": "readyToCheck", 47 | "otaProgress": 0, 48 | "otaPolicy": "autoUpdate", 49 | "otaScheduleStart": "00:00", 50 | "otaScheduleEnd": "00:00", 51 | "circadianRhythmMode": "" 52 | }, 53 | "capabilities": { 54 | "canSend": [], 55 | "canReceive": ["customName", "isOn", "lightLevel", "colorTemperature", "colorSaturation", "colorHue"] 56 | }, 57 | "room": { 58 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 59 | "name": "Bedroom", 60 | "color": "ikea_yellow_no_24", 61 | "icon": "rooms_bed" 62 | }, 63 | "deviceSet": [], 64 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 65 | "isHidden": false 66 | }""" 67 | return dict_to_light(json.loads(data), fake_client) 68 | 69 | 70 | def test_refresh(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 71 | data: Dict[str, Any] = { 72 | "id": "23taswdg-sdf-4eeb-99c2-23asdf2gw", 73 | "type": "light", 74 | "deviceType": "light", 75 | "createdAt": "2023-01-07T20:07:19.000Z", 76 | "isReachable": False, 77 | "lastSeen": "2023-10-28T04:42:15.000Z", 78 | "customIcon": "lighting_nightstand_light", 79 | "attributes": { 80 | "customName": "Bed", 81 | "model": "TRADFRIbulbE27WSglobeopal1055lm", 82 | "manufacturer": "IKEA of Sweden", 83 | "firmwareVersion": "1.0.012", 84 | "hardwareVersion": "1", 85 | "serialNumber": "04CD15FFFEC08659", 86 | "productCode": "LED2003G10", 87 | "isOn": False, 88 | "lightLevel": 13, 89 | "colorTemperature": 2222, 90 | "colorTemperatureMin": 4000, 91 | "colorTemperatureMax": 2202, 92 | "colorHue": 100, 93 | "colorSaturation": 0.8, 94 | "startupTemperature": -1, 95 | "colorMode": "temperature", 96 | "identifyStarted": "2000-01-01T00:00:00.000Z", 97 | "identifyPeriod": 0, 98 | "permittingJoin": True, 99 | "otaStatus": "upToDate", 100 | "otaState": "readyToCheck", 101 | "otaProgress": 0, 102 | "otaPolicy": "autoUpdate", 103 | "otaScheduleStart": "00:00", 104 | "otaScheduleEnd": "00:00", 105 | "circadianRhythmMode": "", 106 | }, 107 | "capabilities": { 108 | "canSend": [], 109 | "canReceive": ["customName", "isOn", "lightLevel", "colorTemperature"], 110 | }, 111 | "room": { 112 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 113 | "name": "Bedroom", 114 | "color": "ikea_yellow_no_24", 115 | "icon": "rooms_bed", 116 | }, 117 | "deviceSet": [], 118 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 119 | "isHidden": False, 120 | } 121 | fake_client.get_action_replys[f"/devices/{fake_light.id}"] = data 122 | fake_light = fake_light.reload() 123 | fake_client.get_actions.pop() 124 | assert fake_light.dirigera_client == fake_client 125 | assert fake_light.id == data["id"] 126 | assert fake_light.is_reachable == data["isReachable"] 127 | assert fake_light.attributes.custom_name == data["attributes"]["customName"] 128 | assert fake_light.attributes.is_on == data["attributes"]["isOn"] 129 | assert fake_light.attributes.startup_on_off is None 130 | assert fake_light.attributes.light_level == data["attributes"]["lightLevel"] 131 | assert ( 132 | fake_light.attributes.color_temperature 133 | == data["attributes"]["colorTemperature"] 134 | ) 135 | assert ( 136 | fake_light.attributes.color_temperature_min 137 | == data["attributes"]["colorTemperatureMin"] 138 | ) 139 | assert ( 140 | fake_light.attributes.color_temperature_max 141 | == data["attributes"]["colorTemperatureMax"] 142 | ) 143 | assert fake_light.capabilities.can_receive == data["capabilities"]["canReceive"] 144 | assert fake_light.room.id == data["room"]["id"] 145 | assert fake_light.room.name == data["room"]["name"] 146 | assert ( 147 | fake_light.attributes.firmware_version == data["attributes"]["firmwareVersion"] 148 | ) 149 | assert ( 150 | fake_light.attributes.hardware_version == data["attributes"]["hardwareVersion"] 151 | ) 152 | assert fake_light.attributes.model == data["attributes"]["model"] 153 | assert fake_light.attributes.manufacturer == data["attributes"]["manufacturer"] 154 | assert fake_light.attributes.serial_number == data["attributes"]["serialNumber"] 155 | 156 | 157 | def test_set_name(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 158 | new_name = "stadtlampefluss" 159 | fake_light.set_name(new_name) 160 | action = fake_client.patch_actions.pop() 161 | assert action["route"] == f"/devices/{fake_light.id}" 162 | assert action["data"] == [{"attributes": {"customName": new_name}}] 163 | assert fake_light.attributes.custom_name == new_name 164 | 165 | 166 | def test_set_light_on(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 167 | fake_light.set_light(True) 168 | action = fake_client.patch_actions.pop() 169 | assert action["route"] == f"/devices/{fake_light.id}" 170 | assert action["data"] == [{"attributes": {"isOn": True}}] 171 | assert fake_light.attributes.is_on 172 | 173 | 174 | def test_set_light_off(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 175 | fake_light.set_light(False) 176 | action = fake_client.patch_actions.pop() 177 | assert action["route"] == f"/devices/{fake_light.id}" 178 | assert action["data"] == [{"attributes": {"isOn": False}}] 179 | assert not fake_light.attributes.is_on 180 | 181 | 182 | def test_set_light_level(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 183 | level = 80 184 | fake_light.set_light_level(level) 185 | action = fake_client.patch_actions.pop() 186 | assert action["route"] == f"/devices/{fake_light.id}" 187 | assert action["data"] == [{"attributes": {"lightLevel": level}}] 188 | assert fake_light.attributes.light_level == level 189 | 190 | 191 | def test_set_color_temperature(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 192 | temp = 2203 193 | fake_light.set_color_temperature(temp) 194 | action = fake_client.patch_actions.pop() 195 | assert action["route"] == f"/devices/{fake_light.id}" 196 | assert action["data"] == [{"attributes": {"colorTemperature": temp}}] 197 | assert fake_light.attributes.color_temperature == temp 198 | 199 | 200 | def test_set_light_color(fake_light: Light, fake_client: FakeDirigeraHub) -> None: 201 | hue = 120 202 | saturation = 0.9 203 | fake_light.set_light_color(hue, saturation) 204 | action = fake_client.patch_actions.pop() 205 | assert action["route"] == f"/devices/{fake_light.id}" 206 | assert action["data"] == [ 207 | {"attributes": {"colorHue": hue, "colorSaturation": saturation}} 208 | ] 209 | assert fake_light.attributes.color_hue == hue 210 | assert fake_light.attributes.color_saturation == saturation 211 | 212 | 213 | def test_set_startup_behaviour_off( 214 | fake_light: Light, fake_client: FakeDirigeraHub 215 | ) -> None: 216 | behaviour = StartupEnum.START_OFF 217 | fake_light.set_startup_behaviour(behaviour) 218 | action = fake_client.patch_actions.pop() 219 | assert action["route"] == f"/devices/{fake_light.id}" 220 | assert action["data"] == [{"attributes": {"startupOnOff": behaviour.value}}] 221 | assert fake_light.attributes.startup_on_off == behaviour 222 | 223 | 224 | def test_set_startup_behaviour_on( 225 | fake_light: Light, fake_client: FakeDirigeraHub 226 | ) -> None: 227 | behaviour = StartupEnum.START_ON 228 | fake_light.set_startup_behaviour(behaviour) 229 | action = fake_client.patch_actions.pop() 230 | assert action["route"] == f"/devices/{fake_light.id}" 231 | assert action["data"] == [{"attributes": {"startupOnOff": behaviour.value}}] 232 | assert fake_light.attributes.startup_on_off == behaviour 233 | 234 | 235 | def test_set_startup_behaviour_previous( 236 | fake_light: Light, fake_client: FakeDirigeraHub 237 | ) -> None: 238 | behaviour = StartupEnum.START_PREVIOUS 239 | fake_light.set_startup_behaviour(behaviour) 240 | action = fake_client.patch_actions.pop() 241 | assert action["route"] == f"/devices/{fake_light.id}" 242 | assert action["data"] == [{"attributes": {"startupOnOff": behaviour.value}}] 243 | assert fake_light.attributes.startup_on_off == behaviour 244 | 245 | 246 | def test_set_startup_behaviour_toggle( 247 | fake_light: Light, fake_client: FakeDirigeraHub 248 | ) -> None: 249 | behaviour = StartupEnum.START_TOGGLE 250 | fake_light.set_startup_behaviour(behaviour) 251 | action = fake_client.patch_actions.pop() 252 | assert action["route"] == f"/devices/{fake_light.id}" 253 | assert action["data"] == [{"attributes": {"startupOnOff": behaviour.value}}] 254 | assert fake_light.attributes.startup_on_off == behaviour 255 | 256 | 257 | def test_dict_to_light_3rdparty(fake_client: FakeDirigeraHub) -> None: 258 | data: Dict[str, Any] = { 259 | "id": "1237-343-2dfa", 260 | "type": "light", 261 | "deviceType": "light", 262 | "createdAt": "2023-01-07T20:07:19.000Z", 263 | "isReachable": True, 264 | "lastSeen": "2023-10-28T04:42:15.000Z", 265 | "customIcon": "lighting_nightstand_light", 266 | "attributes": { 267 | "customName": "Light 2", 268 | "model": "3rd party bulb no startupOnOff", 269 | "manufacturer": "3rd party", 270 | "firmwareVersion": "2.3.093", 271 | "hardwareVersion": "2", 272 | "serialNumber": "84", 273 | "productCode": "LED2003G10", 274 | "isOn": False, 275 | "lightLevel": 100, 276 | "colorTemperature": 2710, 277 | "colorTemperatureMin": 4000, 278 | "colorTemperatureMax": 2202, 279 | "colorMode": "temperature", 280 | "identifyStarted": "2000-01-01T00:00:00.000Z", 281 | "identifyPeriod": 0, 282 | "permittingJoin": True, 283 | "otaStatus": "upToDate", 284 | "otaState": "readyToCheck", 285 | "otaProgress": 0, 286 | "otaPolicy": "autoUpdate", 287 | "otaScheduleStart": "00:00", 288 | "otaScheduleEnd": "00:00", 289 | "circadianRhythmMode": "", 290 | }, 291 | "capabilities": { 292 | "canSend": [], 293 | "canReceive": ["customName", "isOn", "lightLevel"], 294 | }, 295 | "room": { 296 | "id": "23g2w34-d0b7-42b3-a5a1-324zaerfg3", 297 | "name": "Bedroom", 298 | "color": "ikea_yellow_no_24", 299 | "icon": "rooms_bed", 300 | }, 301 | "deviceSet": [], 302 | "remoteLinks": ["3838120-12f0-256-9c63-bdf2dfg232"], 303 | "isHidden": False, 304 | } 305 | 306 | light = dict_to_light(data, fake_client) 307 | assert light.dirigera_client == fake_client 308 | assert light.id == data["id"] 309 | assert light.is_reachable == data["isReachable"] 310 | assert light.attributes.custom_name == data["attributes"]["customName"] 311 | assert light.attributes.is_on == data["attributes"]["isOn"] 312 | assert light.attributes.startup_on_off is None 313 | assert light.attributes.light_level == data["attributes"]["lightLevel"] 314 | assert light.attributes.color_temperature == data["attributes"]["colorTemperature"] 315 | assert ( 316 | light.attributes.color_temperature_min 317 | == data["attributes"]["colorTemperatureMin"] 318 | ) 319 | assert ( 320 | light.attributes.color_temperature_max 321 | == data["attributes"]["colorTemperatureMax"] 322 | ) 323 | assert light.capabilities.can_receive == data["capabilities"]["canReceive"] 324 | assert light.room.id == data["room"]["id"] 325 | assert light.room.name == data["room"]["name"] 326 | assert light.attributes.firmware_version == data["attributes"]["firmwareVersion"] 327 | assert light.attributes.hardware_version == data["attributes"]["hardwareVersion"] 328 | assert light.attributes.model == data["attributes"]["model"] 329 | assert light.attributes.manufacturer == data["attributes"]["manufacturer"] 330 | assert light.attributes.serial_number == data["attributes"]["serialNumber"] 331 | -------------------------------------------------------------------------------- /src/dirigera/hub/hub.py: -------------------------------------------------------------------------------- 1 | # pylint:disable=too-many-public-methods 2 | import ssl 3 | from typing import Any, Dict, List, Optional 4 | import requests 5 | import websocket # type: ignore 6 | import urllib3 7 | from requests import HTTPError 8 | from urllib3.exceptions import InsecureRequestWarning 9 | 10 | from .utils import camelize_dict 11 | from ..devices.device import Device 12 | from .abstract_smart_home_hub import AbstractSmartHomeHub 13 | from ..devices.air_purifier import AirPurifier, dict_to_air_purifier 14 | from ..devices.light import Light, dict_to_light 15 | from ..devices.blinds import Blind, dict_to_blind 16 | from ..devices.controller import Controller, dict_to_controller 17 | from ..devices.outlet import Outlet, dict_to_outlet 18 | from ..devices.environment_sensor import EnvironmentSensor, dict_to_environment_sensor 19 | from ..devices.motion_sensor import MotionSensor, dict_to_motion_sensor 20 | from ..devices.open_close_sensor import OpenCloseSensor, dict_to_open_close_sensor 21 | from ..devices.scene import Action, Info, Scene, SceneType, Trigger, dict_to_scene 22 | from ..devices.water_sensor import WaterSensor, dict_to_water_sensor 23 | from ..devices.occupancy_sensor import OccupancySensor, dict_to_occupancy_sensor 24 | from ..devices.light_sensor import LightSensor, dict_to_light_sensor 25 | 26 | urllib3.disable_warnings(category=InsecureRequestWarning) 27 | 28 | 29 | class Hub(AbstractSmartHomeHub): 30 | def __init__( 31 | self, 32 | token: str, 33 | ip_address: str, 34 | port: str = "8443", 35 | api_version: str = "v1", 36 | ) -> None: 37 | """ 38 | Initializes a new instance of the Hub class. 39 | 40 | Args: 41 | token (str): The authentication token for the hub. 42 | ip_address (str): The IP address of the hub. 43 | port (str, optional): The port number for the hub API. Defaults to "8443". 44 | api_version (str, optional): The version of the API to use. Defaults to "v1". 45 | """ 46 | self.api_base_url = f"https://{ip_address}:{port}/{api_version}" 47 | self.websocket_base_url = f"wss://{ip_address}:{port}/{api_version}" 48 | self.token = token 49 | self.wsapp: Any = None 50 | 51 | def headers(self) -> Dict[str, Any]: 52 | return {"Authorization": f"Bearer {self.token}"} 53 | 54 | def create_event_listener( 55 | self, 56 | on_open: Any = None, 57 | on_message: Any = None, 58 | on_error: Any = None, 59 | on_close: Any = None, 60 | on_ping: Any = None, 61 | on_pong: Any = None, 62 | on_data: Any = None, 63 | on_cont_message: Any = None, 64 | ping_intervall: int = 60, 65 | ) -> None: 66 | """ 67 | Create an event listener. 68 | 69 | Args: 70 | on_open (Any, optional) 71 | on_message (Any, optional) 72 | on_error (Any, optional) 73 | on_close (Any, optional) 74 | on_ping (Any, optional) 75 | on_pong (Any, optional) 76 | on_data (Any, optional) 77 | on_cont_message (Any, optional) 78 | ping_intervall (int, optional): Ping interval in Seconds. Defaults to 60. 79 | """ 80 | self.wsapp = websocket.WebSocketApp( 81 | self.websocket_base_url, 82 | header={"Authorization": f"Bearer {self.token}"}, 83 | on_open=on_open, 84 | on_message=on_message, 85 | on_error=on_error, 86 | on_close=on_close, 87 | on_ping=on_ping, 88 | on_pong=on_pong, 89 | on_data=on_data, 90 | on_cont_message=on_cont_message, 91 | ) 92 | 93 | self.wsapp.run_forever( 94 | sslopt={"cert_reqs": ssl.CERT_NONE}, ping_interval=ping_intervall 95 | ) 96 | 97 | def stop_event_listener(self) -> None: 98 | if self.wsapp is not None: 99 | self.wsapp.close() 100 | self.wsapp = None 101 | 102 | def patch(self, route: str, data: List[Dict[str, Any]]) -> Any: 103 | response = requests.patch( 104 | f"{self.api_base_url}{route}", 105 | headers=self.headers(), 106 | json=data, 107 | timeout=10, 108 | verify=False, 109 | ) 110 | response.raise_for_status() 111 | return response.text 112 | 113 | def get(self, route: str) -> Any: 114 | response = requests.get( 115 | f"{self.api_base_url}{route}", 116 | headers=self.headers(), 117 | timeout=10, 118 | verify=False, 119 | ) 120 | response.raise_for_status() 121 | return response.json() 122 | 123 | def post(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 124 | response = requests.post( 125 | f"{self.api_base_url}{route}", 126 | headers=self.headers(), 127 | json=data, 128 | timeout=10, 129 | verify=False, 130 | ) 131 | if not response.ok: 132 | print(response.text) 133 | response.raise_for_status() 134 | 135 | if len(response.content) == 0: 136 | return None 137 | 138 | return response.json() 139 | 140 | def delete(self, route: str, data: Optional[Dict[str, Any]] = None) -> Any: 141 | response = requests.delete( 142 | f"{self.api_base_url}{route}", 143 | headers=self.headers(), 144 | json=data, 145 | timeout=10, 146 | verify=False, 147 | ) 148 | response.raise_for_status() 149 | 150 | if len(response.content) == 0: 151 | return None 152 | 153 | return response.json() 154 | 155 | def _get_device_data_by_id(self, id_: str) -> Dict: 156 | """ 157 | Fetches device data by its id 158 | """ 159 | try: 160 | return self.get("/devices/" + id_) 161 | except HTTPError as err: 162 | if err.response is not None and err.response.status_code == 404: 163 | raise ValueError("Device id not found") from err 164 | raise err 165 | 166 | def get_air_purifiers(self) -> List[AirPurifier]: 167 | """ 168 | Fetches all air purifiers registered in the Hub 169 | """ 170 | devices = self.get("/devices") 171 | airpurifiers = list(filter(lambda x: x["type"] == "airPurifier", devices)) 172 | return [dict_to_air_purifier(air_p, self) for air_p in airpurifiers] 173 | 174 | def get_air_purifier_by_id(self, id_: str) -> AirPurifier: 175 | air_purifier_device = self._get_device_data_by_id(id_) 176 | if air_purifier_device["deviceType"] != "airPurifier": 177 | raise ValueError("Device is not an Air Purifier") 178 | return dict_to_air_purifier(air_purifier_device, self) 179 | 180 | def get_lights(self) -> List[Light]: 181 | """ 182 | Fetches all lights registered in the Hub 183 | """ 184 | devices = self.get("/devices") 185 | lights = list(filter(lambda x: x["type"] == "light", devices)) 186 | return [dict_to_light(light, self) for light in lights] 187 | 188 | def get_light_by_name(self, lamp_name: str) -> Light: 189 | """ 190 | Fetches all lights and returns first result that matches this name 191 | """ 192 | lights = self.get_lights() 193 | lights = list(filter(lambda x: x.attributes.custom_name == lamp_name, lights)) 194 | if len(lights) == 0: 195 | raise AssertionError(f"No light found with name {lamp_name}") 196 | return lights[0] 197 | 198 | def get_light_by_id(self, id_: str) -> Light: 199 | """ 200 | Fetches a light by its id if that light does not exist or is a device of another type raises ValueError 201 | """ 202 | light = self._get_device_data_by_id(id_) 203 | if light["type"] != "light": 204 | raise ValueError("Device is not a light") 205 | return dict_to_light(light, self) 206 | 207 | def get_outlets(self) -> List[Outlet]: 208 | """ 209 | Fetches all outlets registered in the Hub 210 | """ 211 | devices = self.get("/devices") 212 | outlets = list(filter(lambda x: x["type"] == "outlet", devices)) 213 | return [dict_to_outlet(outlet, self) for outlet in outlets] 214 | 215 | def get_outlet_by_name(self, outlet_name: str) -> Outlet: 216 | """ 217 | Fetches all outlets and returns first result that matches this name 218 | """ 219 | outlets = self.get_outlets() 220 | outlets = list( 221 | filter(lambda x: x.attributes.custom_name == outlet_name, outlets) 222 | ) 223 | if len(outlets) == 0: 224 | raise AssertionError(f"No outlet found with name {outlet_name}") 225 | return outlets[0] 226 | 227 | def get_outlet_by_id(self, id_: str) -> Outlet: 228 | """ 229 | Fetches an outlet by its id if that outlet does not exist or is a device of another type raises ValueError 230 | """ 231 | outlet = self._get_device_data_by_id(id_) 232 | if outlet["type"] != "outlet": 233 | raise ValueError("Device is not an outlet") 234 | return dict_to_outlet(outlet, self) 235 | 236 | def get_environment_sensors(self) -> List[EnvironmentSensor]: 237 | """ 238 | Fetches all environment sensors registered in the Hub 239 | """ 240 | devices = self.get("/devices") 241 | sensors = list( 242 | filter(lambda x: x["deviceType"] == "environmentSensor", devices) 243 | ) 244 | return [dict_to_environment_sensor(sensor, self) for sensor in sensors] 245 | 246 | def get_environment_sensor_by_id(self, id_: str) -> EnvironmentSensor: 247 | environment_sensor = self._get_device_data_by_id(id_) 248 | if environment_sensor["deviceType"] != "environmentSensor": 249 | raise ValueError("Device is not an EnvironmentSensor") 250 | return dict_to_environment_sensor(environment_sensor, self) 251 | 252 | def get_motion_sensors(self) -> List[MotionSensor]: 253 | """ 254 | Fetches all motion sensors registered in the Hub 255 | """ 256 | devices = self.get("/devices") 257 | sensors = list(filter(lambda x: x["deviceType"] == "motionSensor", devices)) 258 | return [dict_to_motion_sensor(sensor, self) for sensor in sensors] 259 | 260 | def get_motion_sensor_by_name(self, motion_sensor_name: str) -> MotionSensor: 261 | """ 262 | Fetches all motion sensors and returns first result that matches this name 263 | """ 264 | motion_sensors = self.get_motion_sensors() 265 | motion_sensors = list(filter(lambda x: x.attributes.custom_name == motion_sensor_name, motion_sensors)) 266 | if len(motion_sensors) == 0: 267 | raise AssertionError(f"No motion sensor found with name {motion_sensor_name}") 268 | return motion_sensors[0] 269 | 270 | def get_motion_sensor_by_id(self, id_: str) -> MotionSensor: 271 | motion_sensor = self._get_device_data_by_id(id_) 272 | if motion_sensor["deviceType"] != "motionSensor": 273 | raise ValueError("Device is not an MotionSensor") 274 | return dict_to_motion_sensor(motion_sensor, self) 275 | 276 | def get_open_close_sensors(self) -> List[OpenCloseSensor]: 277 | """ 278 | Fetches all open/close sensors registered in the Hub 279 | """ 280 | devices = self.get("/devices") 281 | sensors = list(filter(lambda x: x["deviceType"] == "openCloseSensor", devices)) 282 | return [dict_to_open_close_sensor(sensor, self) for sensor in sensors] 283 | 284 | def get_open_close_by_id(self, id_: str) -> OpenCloseSensor: 285 | open_close_sensor = self._get_device_data_by_id(id_) 286 | if open_close_sensor["deviceType"] != "openCloseSensor": 287 | raise ValueError("Device is not an OpenCloseSensor") 288 | return dict_to_open_close_sensor(open_close_sensor, self) 289 | 290 | def get_blinds(self) -> List[Blind]: 291 | """ 292 | Fetches all blinds registered in the Hub 293 | """ 294 | devices = self.get("/devices") 295 | blinds = list(filter(lambda x: x["type"] == "blinds", devices)) 296 | return [dict_to_blind(blind, self) for blind in blinds] 297 | 298 | def get_blind_by_name(self, blind_name: str) -> Blind: 299 | """ 300 | Fetches all blinds and returns first result that matches this name 301 | """ 302 | blinds = self.get_blinds() 303 | blinds = list(filter(lambda x: x.attributes.custom_name == blind_name, blinds)) 304 | if len(blinds) == 0: 305 | raise AssertionError(f"No blind found with name {blind_name}") 306 | return blinds[0] 307 | 308 | def get_blinds_by_id(self, id_: str) -> Blind: 309 | blind_sensor = self._get_device_data_by_id(id_) 310 | if blind_sensor["deviceType"] != "blinds": 311 | raise ValueError("Device is not a Blind") 312 | return dict_to_blind(blind_sensor, self) 313 | 314 | def get_controllers(self) -> List[Controller]: 315 | """ 316 | Fetches all controllers registered in the Hub 317 | """ 318 | devices = self.get("/devices") 319 | controllers = list(filter(lambda x: x["type"] == "controller", devices)) 320 | return [dict_to_controller(controller, self) for controller in controllers] 321 | 322 | def get_controller_by_name(self, controller_name: str) -> Controller: 323 | """ 324 | Fetches all controllers and returns first result that matches this name 325 | """ 326 | controllers = self.get_controllers() 327 | controllers = list( 328 | filter(lambda x: x.attributes.custom_name == controller_name, controllers) 329 | ) 330 | if len(controllers) == 0: 331 | raise AssertionError(f"No controller found with name {controller_name}") 332 | return controllers[0] 333 | 334 | def get_controller_by_id(self, id_: str) -> Controller: 335 | """ 336 | Fetches a controller by its id 337 | if that controller does not exist or is a device of another type raises ValueError 338 | """ 339 | controller = self._get_device_data_by_id(id_) 340 | if controller["type"] != "controller": 341 | raise ValueError("Device is not a controller") 342 | return dict_to_controller(controller, self) 343 | 344 | def get_scenes(self) -> List[Scene]: 345 | """ 346 | Fetches all scenes 347 | """ 348 | scenes: List = self.get("/scenes") 349 | return [dict_to_scene(scene, self) for scene in scenes] 350 | 351 | def get_scene_by_id(self, scene_id: str) -> Scene: 352 | """ 353 | Fetches a specific scene by a given id 354 | """ 355 | data = self.get(f"/scenes/{scene_id}") 356 | return dict_to_scene(data, self) 357 | 358 | def get_scene_by_name(self, scene_name: str) -> Scene: 359 | """ 360 | Fetches all scenes and returns the first result that matches scene_name 361 | """ 362 | scenes = self.get_scenes() 363 | scenes = list(filter(lambda x: x.info.name == scene_name, scenes)) 364 | if len(scenes) == 0: 365 | raise AssertionError(f"No Scene found with name {scene_name}") 366 | return scenes[0] 367 | 368 | def get_water_sensors(self) -> List[WaterSensor]: 369 | """ 370 | Fetches all water sensors registered in the Hub 371 | """ 372 | devices = self.get("/devices") 373 | water_sensors = list( 374 | filter(lambda x: x["deviceType"] == "waterSensor", devices) 375 | ) 376 | return [ 377 | dict_to_water_sensor(water_sensor, self) for water_sensor in water_sensors 378 | ] 379 | 380 | def get_water_sensor_by_id(self, id_: str) -> WaterSensor: 381 | """ 382 | Fetches a water sensor by its id 383 | if that water sensors does not exist or is a device of another type raises ValueError 384 | """ 385 | water_sensor = self._get_device_data_by_id(id_) 386 | if water_sensor["deviceType"] != "waterSensor": 387 | raise ValueError("Device is not a WaterSensor") 388 | return dict_to_water_sensor(water_sensor, self) 389 | 390 | def get_light_sensors(self) -> List[LightSensor]: 391 | """ 392 | Fetches all light sensors registered in the Hub 393 | """ 394 | devices = self.get("/devices") 395 | sensors = list(filter(lambda x: x["deviceType"] == "lightSensor", devices)) 396 | return [dict_to_light_sensor(sensor, self) for sensor in sensors] 397 | 398 | def get_light_sensor_by_id(self, id_: str) -> LightSensor: 399 | """ 400 | Fetches a light sensor by its id 401 | if that light sensor does not exist or is a device of another type raises ValueError 402 | """ 403 | sensor = self._get_device_data_by_id(id_) 404 | if sensor["deviceType"] != "lightSensor": 405 | raise ValueError("Device is not a LightSensor") 406 | return dict_to_light_sensor(sensor, self) 407 | 408 | def get_occupancy_sensors(self) -> List[OccupancySensor]: 409 | """ 410 | Fetches all occupancy sensors registered in the Hub 411 | """ 412 | devices = self.get("/devices") 413 | sensors = list(filter(lambda x: x["deviceType"] == "occupancySensor", devices)) 414 | return [dict_to_occupancy_sensor(sensor, self) for sensor in sensors] 415 | 416 | def get_occupancy_sensor_by_id(self, id_: str) -> OccupancySensor: 417 | """ 418 | Fetches an occupancy sensor by its id 419 | if that occupancy sensor does not exist or is a device of another type raises ValueError 420 | """ 421 | sensor = self._get_device_data_by_id(id_) 422 | if sensor["deviceType"] != "occupancySensor": 423 | raise ValueError("Device is not an OccupancySensor") 424 | return dict_to_occupancy_sensor(sensor, self) 425 | 426 | def get_all_devices(self) -> List[Device]: 427 | """ 428 | Fetches all devices registered in the Hub 429 | """ 430 | devices: List[Device] = [] 431 | devices.extend(self.get_air_purifiers()) 432 | devices.extend(self.get_blinds()) 433 | devices.extend(self.get_controllers()) 434 | devices.extend(self.get_environment_sensors()) 435 | devices.extend(self.get_lights()) 436 | devices.extend(self.get_motion_sensors()) 437 | devices.extend(self.get_open_close_sensors()) 438 | devices.extend(self.get_outlets()) 439 | devices.extend(self.get_water_sensors()) 440 | devices.extend(self.get_occupancy_sensors()) 441 | devices.extend(self.get_light_sensors()) 442 | 443 | return devices 444 | 445 | def create_scene( 446 | self, 447 | info: Info, 448 | scene_type: SceneType = SceneType.USER_SCENE, 449 | triggers: Optional[List[Trigger]] = None, 450 | actions: Optional[List[Action]] = None, 451 | ) -> Scene: 452 | """Creates a new scene. 453 | 454 | Note: 455 | To create an empty scene leave actions and triggers None. 456 | 457 | Args: 458 | info (Info): Name & Icon 459 | type (SceneType): typically USER_SCENE 460 | triggers (List[Trigger]): Triggers for the Scene (An app trigger will be created automatically) 461 | actions (List[Action]): Actions that will be run on Trigger 462 | 463 | Returns: 464 | Scene: Returns the newly created scene. 465 | """ 466 | trigger_list = [] 467 | if triggers: 468 | trigger_list = [ 469 | x.model_dump(mode="json", exclude_none=True) for x in triggers 470 | ] 471 | 472 | action_list = [] 473 | if actions: 474 | action_list = [ 475 | x.model_dump(mode="json", exclude_none=True) for x in actions 476 | ] 477 | data = { 478 | "info": info.model_dump(mode="json", exclude_none=True), 479 | "type": scene_type.value, 480 | "triggers": trigger_list, 481 | "actions": action_list, 482 | } 483 | data = camelize_dict(data) # type: ignore 484 | response_dict = self.post( 485 | "/scenes/", 486 | data=data, 487 | ) 488 | scene_id = response_dict["id"] 489 | return self.get_scene_by_id(scene_id) 490 | 491 | def delete_scene(self, scene_id: str) -> None: 492 | self.delete( 493 | f"/scenes/{scene_id}", 494 | ) 495 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dirigera Python Client 2 | 3 | ![Test](https://github.com/Leggin/dirigera/actions/workflows/tests.yml/badge.svg) 4 | ![Pypi](https://img.shields.io/pypi/v/dirigera) 5 | [![Downloads](https://static.pepy.tech/badge/dirigera/month)](https://pepy.tech/project/dirigera) 6 | ![Downloads](https://img.shields.io/pypi/pyversions/dirigera) 7 | 8 | This repository provides an unofficial Python client for controlling the IKEA Dirigera Smart Home Hub. Current features: 9 | 10 | - [light control](#controlling-lights) 11 | - [outlet control](#controlling-outlets) 12 | - [air purifier control](#controlling-air-purifier) 13 | - [blinds control](#controlling-blinds) 14 | - [remote controllers](#remote-controllers) (tested with STYRBAR) 15 | - [environment sensor](#environment-sensor) (tested with VINDSTYRKA) 16 | - [light sensor](#light-sensor) (tested with MYGGSPRAY) 17 | - [scene](#scene) 18 | - [motion sensor](#motion-sensor) 19 | - [occupancy sensor](#occupancy-sensor) (tested with MYGGSPRAY) 20 | - [open/close sensor](#open-close-sensor) 21 | - [event listener](#event-listener) for hub events 22 | 23 | Support for other features will be added in the future and your input in form of issues and PRs is greatly appreciated. 24 | 25 | ## Installation 26 | 27 | ```bash 28 | pip install dirigera 29 | ``` 30 | 31 | ## Quickstart 32 | 33 | 1. Find out the ip-address of your Dirigera (check your router) 34 | 2. Once you installed `dirigera` with pip you can run the included generate-token script. Here you can directly set the ip-address of you dirigera as parameter. 35 | ```bash 36 | generate-token 37 | ``` 38 | 3. The script starts the auth process. When prompted, you must push the action button on the bottom of your Dirigera. 39 | 4. After that hit ENTER and your `token` will be printed to the console. 40 | Example: 41 | ``` 42 | Press the action button on Dirigera then hit ENTER ... 43 | Your Token: 44 | mgwB.aXqwpzV89N0aUwBhZMJjD8a.UBPyzy2InGtqgwo2MO5.xX4ug7.uBcVJquwYzLnAijF7SdYKvNxTo0uzQKahV10A-3ZQOz-UAubGP6sHWt1CJx3QmWZyE7ZcMZKgODXjSzWL1lumKgGz5dUIwFi3rhNxgK-IsBGeGVhNXPt8vGrYEcZePwPvNAIg8RqmlH27L-JZPnkAtP2wHoOdW72Djot3yJsohtEsb0p9mJvoZFSavTlTr4LDuf584vuH5fha5xoR9QhhIvvgbAP-s4EHFqENNi6vrYLHKR.sdqnv4sYw6UH-l6oiPnnRLxinoqBPOlWhlcL9doFviXQE.tZ9X8WVqyBrd0NYHlo9iorEvUbnZuD02BEJrg4NLwgh3rZtyF0Mi46HenynzBohbPn4RnuSYYCiHt5EZnWedxBtDqc7mSTm1ZtyD 45 | ``` 46 | 5. Done. Use this token in the hub setup. 47 | ``` 48 | dirigera.Hub( 49 | token="mgwB.aXqwpzV89N0aUwBhZMJjD8a...", 50 | ip_address="192.1..." 51 | ) 52 | ``` 53 | 54 | ## [Dirigera Hub](./src/dirigera/hub/hub.py) 55 | 56 | Setting up the client works by providing the token and ip address. 57 | 58 | ```python 59 | import dirigera 60 | 61 | dirigera_hub = dirigera.Hub( 62 | token="mgwB.aXqwpzV89N0aUwBhZMJjD8a...", 63 | ip_address="192.1..." 64 | ) 65 | ``` 66 | 67 |
68 | List dirigera_hub functions 69 | 70 | ```python 71 | # Send a PATCH request to the hub 72 | response = dirigera_hub.patch(route="/devices/1", data=[{"attributes": {"customName": "new name"}}]) 73 | 74 | # Send a GET request to the hub 75 | response = dirigera_hub.get(route="/devices") 76 | 77 | # Send a POST request to the hub 78 | response = dirigera_hub.post(route="/scenes", data={"name": "new scene"}) 79 | 80 | # Send a DELETE request to the hub 81 | response = dirigera_hub.delete(route="/scenes/1") 82 | 83 | # Fetch all air purifiers registered in the hub 84 | air_purifiers = dirigera_hub.get_air_purifiers() 85 | 86 | # Fetch an air purifier by its ID 87 | air_purifier = dirigera_hub.get_air_purifier_by_id(id_="1") 88 | 89 | # Fetch all lights registered in the hub 90 | lights = dirigera_hub.get_lights() 91 | 92 | # Fetch a light by its name 93 | light = dirigera_hub.get_light_by_name(lamp_name="kitchen light") 94 | 95 | # Fetch a light by its ID 96 | light = dirigera_hub.get_light_by_id(id_="1") 97 | 98 | # Fetch all outlets registered in the hub 99 | outlets = dirigera_hub.get_outlets() 100 | 101 | # Fetch an outlet by its name 102 | outlet = dirigera_hub.get_outlet_by_name(outlet_name="kitchen outlet") 103 | 104 | # Fetch an outlet by its ID 105 | outlet = dirigera_hub.get_outlet_by_id(id_="1") 106 | 107 | # Fetch all environment sensors registered in the hub 108 | sensors = dirigera_hub.get_environment_sensors() 109 | 110 | # Fetch an environment sensor by its ID 111 | sensor = dirigera_hub.get_environment_sensor_by_id(id_="1") 112 | 113 | # Fetch all motion sensors registered in the hub 114 | sensors = dirigera_hub.get_motion_sensors() 115 | 116 | # Fetch a motion sensor by its name 117 | light = dirigera_hub.get_motion_sensor_by_name(motion_sensor_name="kitchen motion sensor") 118 | 119 | # Fetch a motion sensor by its ID 120 | sensor = dirigera_hub.get_motion_sensor_by_id(id_="1") 121 | 122 | # Fetch all open/close sensors registered in the hub 123 | sensors = dirigera_hub.get_open_close_sensors() 124 | 125 | # Fetch an open/close sensor by its ID 126 | sensor = dirigera_hub.get_open_close_by_id(id_="1") 127 | 128 | # Fetch all blinds registered in the hub 129 | blinds = dirigera_hub.get_blinds() 130 | 131 | # Fetch a blind by its name 132 | blind = dirigera_hub.get_blind_by_name(blind_name="living room blind") 133 | 134 | # Fetch a blind by its ID 135 | blind = dirigera_hub.get_blinds_by_id(id_="1") 136 | 137 | # Fetch all controllers registered in the hub 138 | controllers = dirigera_hub.get_controllers() 139 | 140 | # Fetch a controller by its name 141 | controller = dirigera_hub.get_controller_by_name(controller_name="remote control") 142 | 143 | # Fetch a controller by its ID 144 | controller = dirigera_hub.get_controller_by_id(id_="1") 145 | 146 | # Fetch all scenes 147 | scenes = dirigera_hub.get_scenes() 148 | 149 | # Fetch a scene by its ID 150 | scene = dirigera_hub.get_scene_by_id(scene_id="1") 151 | 152 | # Fetch all water sensors registered in the hub 153 | water_sensors = dirigera_hub.get_water_sensors() 154 | 155 | # Fetch a water sensor by its ID 156 | water_sensor = dirigera_hub.get_water_sensor_by_id(id_="1") 157 | 158 | # Fetch all occupancy sensors registered in the hub 159 | occupancy_sensors = dirigera_hub.get_occupancy_sensors() 160 | 161 | # Fetch an occupancy sensor by its ID 162 | occupancy_sensor = dirigera_hub.get_occupancy_sensor_by_id(id_="1") 163 | 164 | # Fetch all light sensors registered in the hub 165 | light_sensors = dirigera_hub.get_light_sensors() 166 | 167 | # Fetch a light sensor by its ID 168 | light_sensor = dirigera_hub.get_light_sensor_by_id(id_="1") 169 | 170 | # Fetch all devices registered in the hub 171 | devices = dirigera_hub.get_all_devices() 172 | 173 | # Create a new scene 174 | scene = dirigera_hub.create_scene( 175 | info=Info(name="New Scene", icon=Icon.SCENES_BOOK), 176 | scene_type=SceneType.USER_SCENE, 177 | triggers=[], 178 | actions=[] 179 | ) 180 | 181 | # Delete a scene by its ID 182 | dirigera_hub.delete_scene(scene_id="1") 183 | ``` 184 | 185 |
186 | 187 | # [Devices](./src/dirigera/devices/device.py) 188 | 189 | All available devices (Light, Controller, Outlet, ...) consist of the core data defined in [device.py](./src/dirigera/devices/device.py): 190 | 191 | ### Core Device Data 192 | 193 | ```python 194 | id: str 195 | relation_id: Optional[str] = None 196 | type: str 197 | device_type: str 198 | created_at: datetime.datetime 199 | is_reachable: bool 200 | last_seen: datetime.datetime 201 | attributes: Attributes 202 | capabilities: Capabilities 203 | room: Optional[Room] = None 204 | device_set: List 205 | remote_links: List[str] 206 | is_hidden: Optional[bool] = None 207 | ``` 208 | 209 | ### Attributes 210 | 211 | All devices have attributes. Some devices have special attributes (for example Light has `is_on``). These are the core attributes each device has: 212 | 213 | ```python 214 | custom_name: str 215 | model: str 216 | manufacturer: str 217 | firmware_version: str 218 | hardware_version: str 219 | serial_number: Optional[str] = None 220 | product_code: Optional[str] = None 221 | ota_status: Optional[str] = None 222 | ota_state: Optional[str] = None 223 | ota_progress: Optional[int] = None 224 | ota_policy: Optional[str] = None 225 | ota_schedule_start: Optional[datetime.time] = None 226 | ota_schedule_end: Optional[datetime.time] = None 227 | ``` 228 | 229 | ### Capabilities 230 | 231 | All devices have capabilities (for some it is just empty lists). Capabilities desrcibe what send/receive actions can be performed: 232 | 233 | ```python 234 | can_send: List[str] 235 | can_receive: List[str] 236 | ``` 237 | 238 | All devices have a room with the corresponging infos. 239 | 240 | ### Room 241 | 242 | ```python 243 | id: str 244 | name: str 245 | color: str 246 | icon: str 247 | ``` 248 | 249 | ## [Controlling Lights](./src/dirigera/devices/light.py) 250 | 251 | To get information about the available lights, you can use the `get_lights()` method: 252 | 253 | ```python 254 | lights = dirigera_hub.get_lights() 255 | ``` 256 | 257 | The light object has the following attributes (additional to the core attributes): 258 | 259 | ```python 260 | startup_on_off: Optional[StartupEnum] = None # Optional attributes are not present on all lights 261 | is_on: bool 262 | light_level: Optional[int] = None 263 | color_temperature: Optional[int] = None 264 | color_temperature_min: Optional[int] = None 265 | color_temperature_max: Optional[int] = None 266 | color_hue: Optional[int] = None 267 | color_saturation: Optional[float] = None 268 | ``` 269 | 270 | Available methods for light are: 271 | 272 | ```python 273 | # Reload the light data from the hub 274 | light.reload() 275 | 276 | # Set the name of the light 277 | light.set_name(name="kitchen light 1") 278 | 279 | # Turn the light on or off 280 | light.set_light(lamp_on=True) 281 | 282 | # Set the light level (1-100) 283 | light.set_light_level(light_level=90) 284 | 285 | # Set the color temperature 286 | light.set_color_temperature(color_temp=3000) 287 | 288 | # Set the light color using hue (0-360) and saturation (0.0-1.0) 289 | light.set_light_color(hue=128, saturation=0.5) 290 | 291 | # Set the startup behavior of the light 292 | light.set_startup_behaviour(behaviour=StartupEnum.START_OFF) 293 | ``` 294 | 295 | ## [Controlling Outlets](./src/dirigera/devices/outlet.py) 296 | 297 | To get information about the available outlets, you can use the `get_outlets()` method: 298 | 299 | ```python 300 | outlets = dirigera_hub.get_outlets() 301 | ``` 302 | 303 | The outlet object has the following attributes (additional to the core attributes): 304 | 305 | ```python 306 | is_on: bool 307 | startup_on_off: Optional[StartupEnum] = None 308 | status_light: Optional[bool] = None 309 | identify_period: Optional[int] = None 310 | permitting_join: Optional[bool] = None 311 | energy_consumed_at_last_reset: Optional[float] = None 312 | current_active_power: Optional[float] = None 313 | current_amps: Optional[float] = None 314 | current_voltage: Optional[float] = None 315 | total_energy_consumed: Optional[float] = None 316 | total_energy_consumed_last_updated: Optional[datetime.datetime] = None 317 | time_of_last_energy_reset: Optional[datetime.datetime] = None 318 | ``` 319 | 320 | Available methods for outlet are: 321 | 322 | ```python 323 | # Reload the air outlet data from the hub 324 | outlet.reload() 325 | 326 | # Set the name of the outlet 327 | outlet.set_name(name="kitchen socket 1") 328 | 329 | # Turn the outlet on or off 330 | outlet.set_on(outlet_on=True) 331 | 332 | # Set the startup behavior of the outlet 333 | outlet.set_startup_behaviour(behaviour=StartupEnum.START_OFF) 334 | ``` 335 | 336 | ## [Controlling Air Purifier](./src/dirigera/devices/air_purifier.py) 337 | 338 | To get information about the available air purifiers, you can use the `get_air_purifiers()` method: 339 | 340 | ```python 341 | air_purifiers = dirigera_hub.get_air_purifiers() 342 | ``` 343 | 344 | The air purifier object has the following attributes (additional to the core attributes): 345 | 346 | ```python 347 | fan_mode: FanModeEnum 348 | fan_mode_sequence: str 349 | motor_state: int 350 | child_lock: bool 351 | status_light: bool 352 | motor_runtime: int 353 | filter_alarm_status: bool 354 | filter_elapsed_time: int 355 | filter_lifetime: int 356 | current_p_m25: int 357 | ``` 358 | 359 | Available methods for blinds are: 360 | 361 | ```python 362 | # Reload the air purifier data from the hub 363 | air_purifier.reload() 364 | 365 | # Set the name of the air purifier 366 | air_purifier.set_name(name="living room purifier") 367 | 368 | # Set the fan mode of the air purifier 369 | air_purifier.set_fan_mode(fan_mode=FanModeEnum.AUTO) 370 | 371 | # Set the motor state of the air purifier (0-50) 372 | air_purifier.set_motor_state(motor_state=42) 373 | 374 | # Enable or disable the child lock 375 | air_purifier.set_child_lock(child_lock=True) 376 | 377 | # Turn the status light on or off 378 | air_purifier.set_status_light(light_state=False) 379 | ``` 380 | 381 | ## [Controlling Blinds](./src/dirigera/devices/blinds.py) 382 | 383 | To get information about the available blinds, you can use the `get_blinds()` method: 384 | 385 | ```python 386 | blinds = dirigera_hub.get_blinds() 387 | ``` 388 | 389 | The blind object has the following attributes (additional to the core attributes): 390 | 391 | ```python 392 | blinds_current_level: Optional[int] = None 393 | blinds_target_level: Optional[int] = None 394 | blinds_state: Optional[str] = None 395 | battery_percentage: Optional[int] = None 396 | ``` 397 | 398 | Available methods for blinds are: 399 | 400 | ```python 401 | # Reload the blind data from the hub 402 | blind.reload() 403 | 404 | # Set the name of the blind 405 | blind.set_name(name="kitchen blind 1") 406 | 407 | # Set the target level of the blind (0-100) 408 | blind.set_target_level(target_level=90) 409 | ``` 410 | 411 | ## [Remote Controllers](./src/dirigera/devices/controller.py) 412 | 413 | Currently only tested with the STYRBAR remote. 414 | 415 | To get information about the available controllers, you can use the `get_controllers()` method: 416 | 417 | ```python 418 | controllers = dirigera_hub.get_controllers() 419 | ``` 420 | 421 | The controller object has the following attributes (additional to the core attributes): 422 | 423 | ```python 424 | is_on: Optional[bool] = None 425 | battery_percentage: Optional[int] = None 426 | switch_label: Optional[str] = None 427 | ``` 428 | 429 | Available methods for controller are: 430 | 431 | ```python 432 | # Reload the controller data from the hub 433 | controller.reload() 434 | 435 | # Set the name of the controller 436 | controller.set_name(name="kitchen remote 1") 437 | ``` 438 | 439 | ## [Environment Sensor](./src/dirigera/devices/environment_sensor.py) 440 | 441 | Currently only tested with the VINDSTYRKA sensor. If you have other sensors please send me the json and I will add support or create a PR. 442 | 443 | To get the environment sensors use: 444 | 445 | ```python 446 | sensors = dirigera_hub.get_environment_sensors() 447 | ``` 448 | 449 | The environment sensor object has the following attributes (additional to the core attributes): 450 | 451 | ```python 452 | current_temperature: Optional[float] = None 453 | current_r_h: Optional[int] = None # current humidity 454 | current_p_m25: Optional[int] = None # current particulate matter 2.5 455 | max_measured_p_m25: Optional[int] = None # maximum measurable particulate matter 2.5 456 | min_measured_p_m25: Optional[int] = None # minimum measurable particulate matter 2.5 457 | voc_index: Optional[int] = None # current volatile organic compound 458 | ``` 459 | 460 | Available methods for environment sensor are: 461 | 462 | ```python 463 | # Reload the environment sensor data from the hub 464 | sensor.reload() 465 | 466 | # Set the name of the environment sensor 467 | sensor.set_name(name="Bathroom Sensor") 468 | ``` 469 | 470 | # [Scene](./src/dirigera/devices/scene.py) 471 | 472 | To get the scenes use: 473 | 474 | ```python 475 | scenes = dirigera_hub.get_scenes() 476 | ``` 477 | 478 | The scene object has the following attributes: 479 | 480 | ```python 481 | id: str 482 | type: SceneType 483 | info: Info 484 | triggers: List[Trigger] 485 | actions: List[Action] 486 | created_at: datetime.datetime 487 | last_completed: Optional[datetime.datetime] = None 488 | last_triggered: Optional[datetime.datetime] = None 489 | last_undo: Optional[datetime.datetime] = None 490 | commands: List[str] 491 | undo_allowed_duration: int 492 | ``` 493 | 494 | Details to the `Trigger`, `Action` and `Info` class can be found in [scene.py](./src/dirigera/devices/scene.py) 495 | 496 | Available methods for scene are: 497 | 498 | ```python 499 | scene.reload() 500 | scene.trigger() 501 | scene.undo() 502 | ``` 503 | 504 | ### Creating a Scene 505 | 506 | To create a scene use the `create_scene()` function. 507 | Example how to create an empty scene: 508 | 509 | ```python 510 | scene = dirigera_hub.create_scene( 511 | info=Info(name="This is empty", icon=Icon.SCENES_BOOK), 512 | ) 513 | ``` 514 | 515 | Actions look like this: 516 | 517 | ```python 518 | class Action(BaseIkeaModel): 519 | id: str 520 | type: str 521 | enabled: Optional[bool] = None 522 | attributes: Optional[ActionAttributes] = None 523 | ``` 524 | 525 | Example how create scene with action: 526 | 527 | ```python 528 | from dirigera.devices.scene import Info, Icon, SceneType, Action, ActionAttributes 529 | 530 | light = dirigera_hub.get_light_by_name("kitchen_lamp") 531 | 532 | scene = dirigera_hub.create_scene( 533 | info=Info(name="Scene with action", icon=Icon.SCENES_BOOK), 534 | scene_type=SceneType.USER_SCENE, 535 | triggers=[], 536 | actions=[Action(id=light.id, type="device", enabled=True, attributes=ActionAttributes(is_on=False))], 537 | ) 538 | ``` 539 | 540 | Triggers look like this: 541 | 542 | ```python 543 | class Trigger(BaseIkeaModel): 544 | id: Optional[str] = None 545 | type: str 546 | triggered_at: Optional[datetime.datetime] = None 547 | disabled: bool 548 | trigger: Optional[TriggerDetails] = None 549 | ``` 550 | 551 | Example how to create scene with trigger: 552 | 553 | ```python 554 | from dirigera.devices.scene import Info, Icon, Trigger, SceneType, TriggerDetails, ControllerType, ClickPattern 555 | 556 | scene = dirigera_hub.create_scene( 557 | info=Info(name="Scene with trigger", icon=Icon.SCENES_HEART), 558 | scene_type=SceneType.USER_SCENE, 559 | triggers=[ 560 | Trigger(type="app", disabled=False), 561 | Trigger(type="controller", disabled=False, 562 | trigger=TriggerDetails(clickPattern=ClickPattern.SINGLE_PRESS, buttonIndex=0, 563 | deviceId="0000aaaa-0000-0000-aa00-0a0aa0a000a0_1", 564 | controllerType=ControllerType.SHORTCUT_CONTROLLER))]) 565 | ``` 566 | 567 | All available icons can be found here: [Icons](./src/dirigera/devices/scene.py) 568 | 569 | ## [Motion Sensor](./src/dirigera/devices/motion_sensor.py) 570 | 571 | To get information about the available motion sensors, you can use the `get_motion_sensors()` method: 572 | 573 | ```python 574 | motions_sensors = dirigera_hub.get_motion_sensors() 575 | ``` 576 | 577 | The motion sensor object has the following attributes (additional to the core attributes): 578 | 579 | ```python 580 | battery_percentage: Optional[int] = None 581 | is_on: bool 582 | light_level: Optional[float] = None 583 | is_detected: Optional[bool] = False 584 | ``` 585 | 586 | Available methods for outlet are: 587 | 588 | ```python 589 | # Reload the motion sensor data from the hub 590 | motion_sensor.reload() 591 | 592 | # Set the name of the motion sensor 593 | motion_sensor.set_name(name="kitchen sensor 1") 594 | ``` 595 | 596 | ## [Open Close Sensor](./src/dirigera/devices/open_close_sensor.py) 597 | 598 | To get information about the available open/close sensors, you can use the `get_open_close_sensors()` method: 599 | 600 | ```python 601 | open_close_sensors = dirigera_hub.get_open_close_sensors() 602 | ``` 603 | 604 | The open_close_sensor object has the following attributes (additional to the core attributes): 605 | 606 | ```python 607 | is_open: bool 608 | battery_percentage: Optional[int] = None 609 | ``` 610 | 611 | Available methods for outlet are: 612 | 613 | ```python 614 | # Reload the open/close sensor data from the hub 615 | open_close_sensor.reload() 616 | 617 | # Set the name of the open/close sensor 618 | open_close_sensor.set_name(name="window 1") 619 | ``` 620 | 621 | ## Event Listener 622 | 623 | The event listener allows you to listen to events that are published by your Dirigera hub. This is useful if you want to automate tasks based on events such as when a light is turned on or off, or when the color temperature of a light is changed. 624 | 625 | ```python 626 | import json 627 | from typing import Any 628 | 629 | 630 | def on_message(ws: Any, message: str): 631 | message_dict = json.loads(message) 632 | data = message_dict["data"] 633 | if data["id"] == bed_light.light_id: 634 | print(f"{message_dict['type']} event on {bed_light.custom_name}, attributes: {data['attributes']}") 635 | 636 | def on_error(ws: Any, message: str): 637 | print(message) 638 | 639 | dirigera_hub.create_event_listener( 640 | on_message=on_message, on_error=on_error 641 | ) 642 | ``` 643 | 644 | ``` 645 | deviceStateChanged event on Bed Light, attributes: {'isOn': False} 646 | ``` 647 | 648 | ## Motivation 649 | 650 | The primary motivation for this project was to provide users with the ability to control the startup behavior of their smart home lamps when there is a power outage. 651 | The default behavior of the hub is to turn on all lights when power is restored, which can be problematic if the user is away from home or on vacation, and a small power fluctuation causes all lights to turn on and stay on. Unfortunately, the IKEA app does not offer a way to change this default behavior. 652 | The `set_startup_behaviour()` function enables users to override the default behavior and choose the startup behavior that best suits their needs (START_ON = turn on light when power is back, START_OFF = light stays off when power is back). 653 | I can not guarantee that all IKEA lamps offer this functionality. 654 | EDIT: This is now an exposed feature in the app. 655 | 656 | ## Contributing 657 | 658 | Contributions are welcome! If you have an idea for a new feature or a bug fix, please post and issue or submit a pull request. 659 | 660 | ### Setup of dev 661 | 662 | For setting up the dev environment I recommend running the `setup.sh` script, which will create a venv and install the `requirements.txt` as well as the `dev-requirements.txt`. 663 | 664 | ### Tests 665 | 666 | To run the tests execute the `run-test.sh` script or just run `pytest .` 667 | For linting you can run the `run-pylint.sh`. 668 | For types you can run the `run-mypy.sh` 669 | To test the different python versions you can use the `run-python-verions-test.sh` (this requires a running docker installation). 670 | All of these tests are also run when a PR is openend (and the test run is triggered). 671 | 672 | ## License 673 | 674 | The MIT License (MIT) 675 | 676 | Copyright (c) 2023 Leggin 677 | --------------------------------------------------------------------------------