├── .gitignore ├── .gitattributes ├── .github ├── config.yml ├── workflows │ ├── lint.yml │ ├── validate.yml │ └── release.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── tests ├── __init__.py ├── fixtures │ ├── group1.json │ ├── group2.json │ ├── cover3.json │ ├── cover4.json │ ├── cover1.json │ └── cover2.json ├── conftest.py ├── test_cover.py └── test_config_flow.py ├── image.png ├── custom_components ├── __init__.py └── homee │ ├── icons.json │ ├── manifest.json │ ├── services.yaml │ ├── helpers.py │ ├── siren.py │ ├── select.py │ ├── event.py │ ├── lock.py │ ├── const.py │ ├── alarm_control_panel.py │ ├── fan.py │ ├── config_flow.py │ ├── entity.py │ ├── switch.py │ ├── binary_sensor.py │ ├── climate.py │ ├── light.py │ ├── number.py │ ├── __init__.py │ ├── cover.py │ ├── translations │ ├── en.json │ └── de.json │ └── strings.json ├── requirements.txt ├── hacs.json ├── .devcontainer ├── configuration.yaml ├── devcontainer.json └── README.md ├── .vscode ├── settings.json └── tasks.json ├── howto_beta.md ├── setup.cfg ├── LICENSE ├── ruff.toml ├── README.md ├── CONTRIBUTING.md └── quality-scale.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | todo: 2 | keyword: ["TODO:"] 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Homee integration.""" 2 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Taraman17/hass-homee/HEAD/image.png -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Helps enable tests for custom component.""" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.7.0 2 | homeassistant==2025.1.0 3 | pip>=24.0 4 | ruff==0.9.0 -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homee", 3 | "hacs": "2.0.0", 4 | "homeassistant": "2025.3.0" 5 | } -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: error 5 | logs: 6 | custom_components.homee: info 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | ruff: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: astral-sh/ruff-action@v3 12 | 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | }, 5 | "python.linting.pylintEnabled": false, 6 | "python.linting.flake8Enabled": true, 7 | "python.linting.enabled": true 8 | } -------------------------------------------------------------------------------- /tests/fixtures/group1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Group1", 4 | "image": "groupicon_sectionaldoor_value_4", 5 | "order": 1, 6 | "added": 1676464568, 7 | "state": 1, 8 | "category": 0, 9 | "phonetic_name": "", 10 | "note": "1st test group", 11 | "services": 1, 12 | "owner": 2 13 | } 14 | -------------------------------------------------------------------------------- /tests/fixtures/group2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "name": "Testgroup2", 4 | "image": "groupicon_doubleplug_value_1_1", 5 | "order": 2, 6 | "added": 1693999785, 7 | "state": 1, 8 | "category": 0, 9 | "phonetic_name": "", 10 | "note": "Another test group", 11 | "services": 1, 12 | "owner": 2 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/homee/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "climate": { 4 | "homee": { 5 | "state_attributes": { 6 | "preset_mode": { 7 | "state": { 8 | "manual": "mdi:hand-back-right" 9 | } 10 | } 11 | } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /howto_beta.md: -------------------------------------------------------------------------------- 1 | # How to switch to a beta-release 2 | 3 | - Go to HACS and click on the integrations button. 4 | - Then click on the homee integration box. 5 | - CLick on the 3-dot-menu on top right of the screen. 6 | - Choose "Download again" and in the dialog activate beta releases. 7 | - Choose the release you want to install and confirm. 8 | 9 | The same way, you can switch back to another beta or the master release. -------------------------------------------------------------------------------- /custom_components/homee/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "homee", 3 | "name": "homee", 4 | "codeowners": [ 5 | "@Taraman17" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/Taraman17/hass-homee", 10 | "homekit": {}, 11 | "integration_type": "hub", 12 | "iot_class": "local_push", 13 | "issue_tracker": "https://github.com/Taraman17/hass-homee/issues", 14 | "requirements": [ 15 | "pyHomee==1.2.8" 16 | ], 17 | "ssdp": [], 18 | "version": "4.0.0" 19 | } -------------------------------------------------------------------------------- /custom_components/homee/services.yaml: -------------------------------------------------------------------------------- 1 | set_value: 2 | fields: 3 | config_entry_id: 4 | required: true 5 | selector: 6 | config_entry: 7 | integration: homee 8 | node: 9 | required: true 10 | selector: 11 | number: 12 | mode: box 13 | example: 36 14 | attribute: 15 | required: true 16 | selector: 17 | number: 18 | mode: box 19 | example: 90 20 | value: 21 | required: true 22 | selector: 23 | number: 24 | mode: box 25 | example: 1.0 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "HACS Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | jobs: 8 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 9 | name: "Hassfest Validation" 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - name: "Checkout the repository" 13 | uses: "actions/checkout@main" 14 | 15 | - name: "Run hassfest validation" 16 | uses: "home-assistant/actions/hassfest@master" 17 | 18 | hacs: # https://github.com/hacs/action 19 | name: "HACS Validation" 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | - name: "Checkout the repository" 23 | uses: "actions/checkout@main" 24 | 25 | - name: "Run HACS validation" 26 | uses: "hacs/action@main" 27 | with: 28 | category: "integration" -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration", 4 | "context": "..", 5 | "appPort": [ 6 | "9123:8123" 7 | ], 8 | "postCreateCommand": "container install", 9 | "runArgs": [ 10 | "-v", 11 | "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" 12 | ], 13 | "extensions": [ 14 | "ms-python.python", 15 | "github.vscode-pull-request-github", 16 | "tabnine.tabnine-vscode" 17 | ], 18 | "settings": { 19 | "files.eol": "\n", 20 | "editor.tabSize": 4, 21 | "terminal.integrated.shell.linux": "/bin/bash", 22 | "python.pythonPath": "/usr/bin/python3", 23 | "python.linting.pylintEnabled": true, 24 | "python.linting.enabled": true, 25 | "python.formatting.provider": "black", 26 | "editor.formatOnPaste": false, 27 | "editor.formatOnSave": true, 28 | "editor.formatOnType": true, 29 | "files.trimTrailingWhitespace": true 30 | } 31 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@main" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/homee/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/homee" 30 | zip homee.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v0.1.15 34 | with: 35 | files: ${{ github.workspace }}/custom_components/homee/homee.zip 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.blueprint 35 | combine_as_imports = true 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Karl Möller @FreshlyBrewedCode 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. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 11 | 12 | ## Type of problem 13 | 14 | - [ ] I have a question. 15 | - [ ] A feature does not work as expected. 16 | - [ ] I found a bug (see error log below). 17 | 18 | ## Version 19 | 22 | 23 | ## Detailed description 24 | A clear and concise description of the question/problem/bug 25 | 26 | 27 | ## Error log 28 | 29 | 30 | 31 | ```text 32 | 33 | Add your logs here. 34 | 35 | ``` 36 | -------------------------------------------------------------------------------- /custom_components/homee/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the homee custom component.""" 2 | 3 | from enum import IntEnum 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import entity_registry as er 9 | 10 | from .const import DOMAIN 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None: 16 | """Return the enum item name for a given integer.""" 17 | try: 18 | item = att_class(att_id) 19 | except ValueError: 20 | _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__) 21 | return None 22 | return item.name.lower() 23 | 24 | 25 | async def migrate_old_unique_ids( 26 | hass: HomeAssistant, devices: list[Any], platform: str 27 | ) -> None: 28 | """Migrate uids for upcoming HA core integration.""" 29 | registry = er.async_get(hass) 30 | for device in devices: 31 | old_entity_id = registry.async_get_entity_id( 32 | platform, DOMAIN, device.old_unique_id 33 | ) 34 | updated_unique_id = device.unique_id 35 | if old_entity_id is not None and updated_unique_id is not None: 36 | _LOGGER.debug( 37 | "Migrating unique_id from [%s] to [%s]", 38 | device.old_unique_id, 39 | device.unique_id, 40 | ) 41 | registry.async_update_entity(old_entity_id, new_unique_id=updated_unique_id) 42 | -------------------------------------------------------------------------------- /custom_components/homee/siren.py: -------------------------------------------------------------------------------- 1 | """The Homee siren platform.""" 2 | 3 | from typing import Any 4 | from pyHomee.const import AttributeType 5 | 6 | from homeassistant.components.siren import SirenEntity, SirenEntityFeature 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | 10 | from . import HomeeConfigEntry 11 | from .entity import HomeeEntity 12 | 13 | PARALLEL_UPDATES = 0 14 | 15 | 16 | async def async_setup_entry( 17 | hass: HomeAssistant, 18 | config_entry: HomeeConfigEntry, 19 | async_add_devices: AddEntitiesCallback, 20 | ) -> None: 21 | """Add the homee platform for the cover integration.""" 22 | 23 | devices: list[HomeeSiren] = [] 24 | for node in config_entry.runtime_data.nodes: 25 | devices.extend( 26 | HomeeSiren(attribute, config_entry) 27 | for attribute in node.attributes 28 | if attribute.type == AttributeType.SIREN 29 | ) 30 | if devices: 31 | async_add_devices(devices) 32 | 33 | 34 | class HomeeSiren(HomeeEntity, SirenEntity): 35 | """Representation of a homee siren device.""" 36 | 37 | _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF 38 | 39 | @property 40 | def is_on(self) -> bool: 41 | """Return the state of the siren.""" 42 | return self._attribute.current_value == 1.0 43 | 44 | async def async_turn_on(self, **kwargs: Any) -> None: 45 | """Turn the siren on.""" 46 | await self.async_set_value(1) 47 | 48 | async def async_turn_off(self, **kwargs: Any) -> None: 49 | """Turn the siren off.""" 50 | await self.async_set_value(0) 51 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py311" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [lint.pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant homee integration 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![License][license-shield]](LICENSE) 5 | 6 | ![Project Maintenance][maintenance-shield] 7 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 8 | 9 | [![Community Forum][forum-shield]][forum] 10 | ![][usage] 11 | 12 | _Component to integrate with [homee][homee]._ 13 | ![grafik][homee_logo] 14 | 15 | Based on the intial work of [FreshlyBrewedCode] 16 | 17 | # Homee is now part of Home Assistant Core! 18 | 19 | Please use the built-in integration, as this one is not actively developed any more. 20 | V4.0.0 is the final version. 21 | 22 | ## Migration 23 | With the latest version installed and HA rebooted, you can just uninstall this integration via HACS, using the "three dot menu" of the integration: 24 | ![HACS Menu](image.png) 25 | 26 | After a reboot, HA will be using the built-in integration. 27 | 28 | ## :warning: Breaking changes 29 | 30 | While integrating into core, I had to change a lot of code to meet Home Assistants code quality requirements and standards. This leads to some entities changing type and leaving the old ones unavailable. Please check release notes for further information. 31 | 32 | ## Wiki 33 | 34 | I will continue the Wiki, to track the status of device support. 35 | 36 | 37 | --- 38 | 39 | [homee]: https://hom.ee 40 | [buymecoffee]: https://ko-fi.com/taraman 41 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 42 | [homee_logo]: https://github.com/home-assistant/brands/blob/master/core_integrations/homee/logo.png 43 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 44 | [forum]: https://community.home-assistant.io/ 45 | [license-shield]: https://img.shields.io/github/license/custom-components/blueprint.svg?style=for-the-badge 46 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Taraman17-blue.svg?style=for-the-badge 47 | [releases-shield]: https://img.shields.io/github/release/Taraman17/hass-homee.svg?style=for-the-badge 48 | [releases]: https://github.com/Taraman17/hass-homee/releases 49 | [FreshlyBrewedCode]: https://github.com/FreshlyBrewedCode 50 | [usage]: https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.homee.total 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [blueprint template](https://github.com/custom-components/blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`.devcontainer/configuration.yaml`](https://github.com/oncleben31/ha-pool_pump/blob/master/.devcontainer/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /custom_components/homee/select.py: -------------------------------------------------------------------------------- 1 | """The Homee select platform.""" 2 | 3 | from pyHomee.const import AttributeType 4 | from pyHomee.model import HomeeAttribute 5 | 6 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 7 | from homeassistant.const import EntityCategory 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 10 | 11 | from . import HomeeConfigEntry 12 | from .entity import HomeeEntity 13 | 14 | PARALLEL_UPDATES = 0 15 | 16 | SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = { 17 | AttributeType.DISPLAY_TEMPERATURE_SELECTION: SelectEntityDescription( 18 | key="display_temperature_selection", 19 | options=["selected", "current"], 20 | entity_category=EntityCategory.CONFIG, 21 | ), 22 | AttributeType.REPEATER_MODE: SelectEntityDescription( 23 | key="repeater_mode", 24 | options=["off", "level1", "level2"], 25 | entity_category=EntityCategory.CONFIG, 26 | ), 27 | } 28 | 29 | 30 | async def async_setup_entry( 31 | hass: HomeAssistant, 32 | config_entry: HomeeConfigEntry, 33 | async_add_entities: AddConfigEntryEntitiesCallback, 34 | ) -> None: 35 | """Add the homee platform for the cover integration.""" 36 | 37 | async_add_entities( 38 | HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) 39 | for node in config_entry.runtime_data.nodes 40 | for attribute in node.attributes 41 | if attribute.type in SELECT_DESCRIPTIONS and attribute.editable 42 | ) 43 | 44 | 45 | class HomeeSelect(HomeeEntity, SelectEntity): 46 | """Representation of a homee select device.""" 47 | 48 | def __init__( 49 | self, 50 | attribute: HomeeAttribute, 51 | entry: HomeeConfigEntry, 52 | description: SelectEntityDescription, 53 | ) -> None: 54 | """Initialize a homee sensor entity.""" 55 | super().__init__(attribute, entry) 56 | self.entity_description = description 57 | assert description.options is not None 58 | self._attr_options = description.options 59 | self._attr_translation_key = description.key 60 | 61 | @property 62 | def current_option(self) -> str: 63 | """Return the current selected option.""" 64 | return self.options[int(self._attribute.current_value)] 65 | 66 | async def async_select_option(self, option: str) -> None: 67 | """Change the selected option.""" 68 | await self.async_set_value(self.options.index(option)) 69 | -------------------------------------------------------------------------------- /custom_components/homee/event.py: -------------------------------------------------------------------------------- 1 | """The homee event platform.""" 2 | 3 | from pyHomee.const import AttributeType 4 | from pyHomee.model import HomeeAttribute 5 | 6 | from homeassistant.components.event import ( 7 | EventDeviceClass, 8 | EventEntity, 9 | EventEntityDescription, 10 | ) 11 | from homeassistant.const import Platform 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from . import HomeeConfigEntry 16 | from .entity import HomeeEntity 17 | from .helpers import migrate_old_unique_ids 18 | 19 | PARALLEL_UPDATES = 0 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: HomeeConfigEntry, 25 | async_add_devices: AddEntitiesCallback, 26 | ) -> None: 27 | """Add the homee platform for the event component.""" 28 | 29 | devices: list[HomeeEvent] = [] 30 | for node in config_entry.runtime_data.nodes: 31 | devices.extend( 32 | HomeeEvent(attribute, config_entry) 33 | for attribute in node.attributes 34 | if (attribute.type == AttributeType.UP_DOWN_REMOTE) 35 | ) 36 | if devices: 37 | await migrate_old_unique_ids(hass, devices, Platform.EVENT) 38 | async_add_devices(devices) 39 | 40 | 41 | class HomeeEvent(HomeeEntity, EventEntity): 42 | """Representation of a homee event.""" 43 | 44 | entity_description = EventEntityDescription( 45 | key="up_down_remote", 46 | device_class=EventDeviceClass.BUTTON, 47 | event_types=["0", "1", "2", "3", "4", "5", "6", "7", "9"], 48 | translation_key="up_down_remote", 49 | has_entity_name=True, 50 | ) 51 | 52 | async def async_added_to_hass(self) -> None: 53 | """Add the homee attribute entity to home assistant.""" 54 | self.async_on_remove( 55 | self._attribute.add_on_changed_listener(self._event_triggered) 56 | ) 57 | self.async_on_remove( 58 | self._entry.runtime_data.add_connection_listener( 59 | self._on_connection_changed 60 | ) 61 | ) 62 | 63 | @property 64 | def old_unique_id(self) -> str: 65 | """Return the old not so unique id of the event entity.""" 66 | return f"{self._attribute.node_id}-event-{self._attribute.id}" 67 | 68 | @callback 69 | def _event_triggered(self, event: HomeeAttribute) -> None: 70 | """Handle a homee event.""" 71 | if event.type == AttributeType.UP_DOWN_REMOTE: 72 | self._trigger_event(str(int(event.current_value))) 73 | self.schedule_update_ha_state() 74 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | ## Developing with Visual Studio Code + devcontainer 2 | 3 | The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. 4 | 5 | In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. 6 | 7 | **Prerequisites** 8 | 9 | - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 10 | - Docker 11 | - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) 12 | - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. 13 | - [Visual Studio code](https://code.visualstudio.com/) 14 | - [Remote - Containers (VSC Extension)][extension-link] 15 | 16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) 17 | 18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 19 | 20 | **Getting started:** 21 | 22 | 1. Fork the repository. 23 | 2. Clone the repository to your computer. 24 | 3. Open the repository using Visual Studio code. 25 | 26 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. 27 | 28 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ 29 | 30 | ### Tasks 31 | 32 | The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. 33 | 34 | When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. 35 | 36 | The available tasks are: 37 | 38 | Task | Description 39 | -- | -- 40 | Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. 41 | Run Home Assistant configuration against /config | Check the configuration. 42 | Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. 43 | Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. 44 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for Homee integration tests.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | import pytest 6 | from collections.abc import Generator 7 | import voluptuous as vol 8 | 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD 12 | import homeassistant.helpers.config_validation as cv 13 | 14 | from custom_components.homee.const import ( 15 | CONF_ADD_HOMEE_DATA, 16 | CONF_DOOR_GROUPS, 17 | CONF_WINDOW_GROUPS, 18 | DOMAIN, 19 | ) 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def auto_enable_custom_integrations(enable_custom_integrations): # pylint: disable=unused-argument 24 | """Automatically enables custom integrations for tests.""" 25 | yield 26 | 27 | 28 | HOMEE_ID = "00055511EECC" 29 | HOMEE_IP = "192.168.1.11" 30 | TESTUSER = "testuser" 31 | TESTPASS = "testpass" 32 | 33 | GROUPS_SELECTION = {"1": "Group1 (0)", "3": "Group2 (0)"} 34 | 35 | SCHEMA_IMPORT_ALL = vol.Schema( 36 | { 37 | vol.Required( 38 | CONF_WINDOW_GROUPS, 39 | default=[], 40 | ): cv.multi_select(GROUPS_SELECTION), 41 | vol.Required( 42 | CONF_DOOR_GROUPS, 43 | default=[], 44 | ): cv.multi_select(GROUPS_SELECTION), 45 | } 46 | ) 47 | 48 | 49 | @pytest.fixture 50 | def mock_config_entry() -> MockConfigEntry: 51 | """Return the default mocked config entry.""" 52 | return MockConfigEntry( 53 | title=f"{HOMEE_ID} ({HOMEE_IP})", 54 | domain=DOMAIN, 55 | data={ 56 | CONF_HOST: HOMEE_IP, 57 | CONF_USERNAME: TESTUSER, 58 | CONF_PASSWORD: TESTPASS, 59 | }, 60 | options={ 61 | CONF_ADD_HOMEE_DATA: False 62 | }, 63 | unique_id=HOMEE_ID, 64 | version=3, 65 | minor_version=1, 66 | ) 67 | 68 | 69 | @pytest.fixture 70 | def mock_setup_entry() -> Generator[AsyncMock]: 71 | """Mock setting up a config entry.""" 72 | with patch( 73 | "custom_components.homee.async_setup_entry", return_value=True 74 | ) as mock_setup: 75 | yield mock_setup 76 | 77 | 78 | @pytest.fixture 79 | def mock_homee() -> Generator[MagicMock]: 80 | """Return a mock Homee instance.""" 81 | with patch( 82 | "custom_components.homee.config_flow.validate_and_connect", autospec=True 83 | ) as mocked_homee: 84 | homee = mocked_homee.return_value 85 | 86 | homee.host = HOMEE_IP 87 | homee.user = TESTUSER 88 | homee.password = TESTPASS 89 | homee.settings.uid = HOMEE_ID 90 | homee.reconnect_interval = 10 91 | 92 | homee.get_access_token.return_value = "test_token" 93 | homee.wait_until_connected.return_value = True 94 | homee.wait_until_disconnected.return_value = True 95 | 96 | yield homee 97 | -------------------------------------------------------------------------------- /custom_components/homee/lock.py: -------------------------------------------------------------------------------- 1 | """The homee lock platform.""" 2 | 3 | from typing import Any 4 | 5 | from pyHomee.const import AttributeChangedBy, AttributeType 6 | 7 | from homeassistant.components.lock import LockEntity 8 | from homeassistant.const import Platform 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from . import HomeeConfigEntry 13 | from .entity import HomeeEntity 14 | from .helpers import get_name_for_enum, migrate_old_unique_ids 15 | 16 | PARALLEL_UPDATES = 0 17 | 18 | 19 | async def async_setup_entry( 20 | hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_devices: AddEntitiesCallback 21 | ) -> None: 22 | """Add the homee platform for the lock component.""" 23 | 24 | devices: list[HomeeLock] = [] 25 | for node in config_entry.runtime_data.nodes: 26 | devices.extend( 27 | HomeeLock(attribute, config_entry) 28 | for attribute in node.attributes 29 | if (attribute.type == AttributeType.LOCK_STATE and attribute.editable) 30 | ) 31 | if devices: 32 | await migrate_old_unique_ids(hass, devices, Platform.LOCK) 33 | async_add_devices(devices) 34 | 35 | 36 | class HomeeLock(HomeeEntity, LockEntity): 37 | """Representation of a homee lock.""" 38 | 39 | _attr_name = None 40 | 41 | @property 42 | def old_unique_id(self) -> str: 43 | """Return the old not so unique id of the lock entity.""" 44 | return f"{self._attribute.node_id}-lock-{self._attribute.id}" 45 | 46 | @property 47 | def is_locked(self) -> bool: 48 | """Return the current lock state.""" 49 | return self._attribute.current_value == 1.0 50 | 51 | @property 52 | def is_locking(self) -> bool: 53 | """Return if lock is locking.""" 54 | return self._attribute.target_value > self._attribute.current_value 55 | 56 | @property 57 | def is_unlocking(self) -> bool: 58 | """Return if lock is unlocking.""" 59 | return self._attribute.target_value < self._attribute.current_value 60 | 61 | @property 62 | def changed_by(self) -> str: 63 | """Return by whom or what the lock was last changed.""" 64 | changed_id = str(self._attribute.changed_by_id) 65 | changed_by_name = get_name_for_enum( 66 | AttributeChangedBy, self._attribute.changed_by 67 | ) 68 | if self._attribute.changed_by == AttributeChangedBy.USER: 69 | changed_id = self._entry.runtime_data.get_user_by_id( 70 | self._attribute.changed_by_id 71 | ).username 72 | 73 | return f"{changed_by_name}-{changed_id}" 74 | 75 | async def async_lock(self, **kwargs: Any) -> None: 76 | """Lock all or specified locks. A code to lock the lock with may be specified.""" 77 | await self.async_set_value(1) 78 | 79 | async def async_unlock(self, **kwargs: Any) -> None: 80 | """Unlock all or specified locks. A code to unlock the lock with may be specified.""" 81 | await self.async_set_value(0) 82 | -------------------------------------------------------------------------------- /tests/test_cover.py: -------------------------------------------------------------------------------- 1 | """Test homee covers.""" 2 | 3 | from homeassistant.core import HomeAssistant 4 | from pyHomee import HomeeNode 5 | from pytest_homeassistant_custom_component.common import ( 6 | MockConfigEntry, 7 | load_json_object_fixture, 8 | ) 9 | 10 | from custom_components.homee.cover import HomeeCover 11 | 12 | 13 | async def test_cover_open( 14 | hass: HomeAssistant, mock_config_entry: MockConfigEntry 15 | ) -> None: 16 | """Test an open cover.""" 17 | mock_config_entry.add_to_hass(hass) 18 | 19 | # Cover open, tilt open. 20 | cover_json = load_json_object_fixture("cover1.json") 21 | cover_node = HomeeNode(cover_json) 22 | cover1 = HomeeCover(cover_node, mock_config_entry) 23 | 24 | assert cover1.unique_id == "3-cover" 25 | 26 | assert cover1.state == "open" 27 | assert cover1.is_closed is False 28 | assert cover1.is_closing is False 29 | assert cover1.is_opening is False 30 | assert round(cover1.current_cover_position) == 100 31 | assert round(cover1.current_cover_tilt_position) == 100 32 | 33 | 34 | async def test_cover_closed( 35 | hass: HomeAssistant, mock_config_entry: MockConfigEntry 36 | ) -> None: 37 | """Test a closed cover.""" 38 | mock_config_entry.add_to_hass(hass) 39 | 40 | # Cover closed, tilt closed. 41 | cover_json = load_json_object_fixture("cover2.json") 42 | cover_node = HomeeNode(cover_json) 43 | cover2 = HomeeCover(cover_node, mock_config_entry) 44 | 45 | assert cover2.state == "closed" 46 | assert cover2.is_closed is True 47 | assert cover2.is_closing is False 48 | assert cover2.is_opening is False 49 | assert round(cover2.current_cover_position) == 0 50 | assert round(cover2.current_cover_tilt_position) == 0 51 | 52 | 53 | async def test_cover_opening( 54 | hass: HomeAssistant, mock_config_entry: MockConfigEntry 55 | ) -> None: 56 | """Test an opening cover.""" 57 | mock_config_entry.add_to_hass(hass) 58 | 59 | # opening, 75% homee / 25% HA 60 | cover_json = load_json_object_fixture("cover3.json") 61 | cover_node = HomeeNode(cover_json) 62 | cover3 = HomeeCover(cover_node, mock_config_entry) 63 | 64 | assert cover3.state == "opening" 65 | assert cover3.is_closed is False 66 | assert cover3.is_closing is False 67 | assert cover3.is_opening is True 68 | assert round(cover3.current_cover_position) == 25 69 | assert round(cover3.current_cover_tilt_position) == 25 70 | 71 | 72 | async def test_cover_closing( 73 | hass: HomeAssistant, mock_config_entry: MockConfigEntry 74 | ) -> None: 75 | """Test a closing cover.""" 76 | mock_config_entry.add_to_hass(hass) 77 | 78 | # closing, 25% homee / 75% HA 79 | cover_json = load_json_object_fixture("cover4.json") 80 | cover_node = HomeeNode(cover_json) 81 | cover4 = HomeeCover(cover_node, mock_config_entry) 82 | 83 | assert cover4.state == "closing" 84 | assert cover4.is_closed is False 85 | assert cover4.is_closing is True 86 | assert cover4.is_opening is False 87 | assert round(cover4.current_cover_position) == 75 88 | assert round(cover4.current_cover_tilt_position) == 75 89 | -------------------------------------------------------------------------------- /custom_components/homee/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the homee integration.""" 2 | 3 | from pyHomee.const import NodeProfile 4 | 5 | from homeassistant.const import ( 6 | DEGREE, 7 | LIGHT_LUX, 8 | PERCENTAGE, 9 | REVOLUTIONS_PER_MINUTE, 10 | UnitOfElectricCurrent, 11 | UnitOfElectricPotential, 12 | UnitOfEnergy, 13 | UnitOfPower, 14 | UnitOfPrecipitationDepth, 15 | UnitOfSpeed, 16 | UnitOfTemperature, 17 | UnitOfTime, 18 | UnitOfVolume, 19 | UnitOfVolumetricFlux, 20 | ) 21 | 22 | # General 23 | DOMAIN = "homee" 24 | 25 | # Sensor mappings 26 | HOMEE_UNIT_TO_HA_UNIT = { 27 | "": None, 28 | "n/a": None, 29 | "text": None, 30 | "%": PERCENTAGE, 31 | "lx": LIGHT_LUX, 32 | "klx": LIGHT_LUX, 33 | "1/min": REVOLUTIONS_PER_MINUTE, 34 | "A": UnitOfElectricCurrent.AMPERE, 35 | "V": UnitOfElectricPotential.VOLT, 36 | "kWh": UnitOfEnergy.KILO_WATT_HOUR, 37 | "W": UnitOfPower.WATT, 38 | "m/s": UnitOfSpeed.METERS_PER_SECOND, 39 | "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, 40 | "°": DEGREE, 41 | "°F": UnitOfTemperature.FAHRENHEIT, 42 | "°C": UnitOfTemperature.CELSIUS, 43 | "K": UnitOfTemperature.KELVIN, 44 | "s": UnitOfTime.SECONDS, 45 | "min": UnitOfTime.MINUTES, 46 | "h": UnitOfTime.HOURS, 47 | "L": UnitOfVolume.LITERS, 48 | "mm/h": UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, 49 | "mm": UnitOfPrecipitationDepth.MILLIMETERS, 50 | } 51 | OPEN_CLOSE_MAP = { 52 | 0.0: "open", 53 | 1.0: "closed", 54 | 2.0: "partial", 55 | 3.0: "opening", 56 | 4.0: "closing", 57 | } 58 | OPEN_CLOSE_MAP_REVERSED = { 59 | 0.0: "closed", 60 | 1.0: "open", 61 | 2.0: "partial", 62 | 3.0: "closing", 63 | 4.0: "opening", 64 | } 65 | WINDOW_MAP = { 66 | 0.0: "closed", 67 | 1.0: "open", 68 | 2.0: "tilted", 69 | } 70 | WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"} 71 | 72 | # Services 73 | SERVICE_SET_VALUE = "set_value" 74 | 75 | # Attributes 76 | ATTR_ATTRIBUTE = "attribute" 77 | ATTR_CONFIG_ENTRY_ID = "config_entry_id" 78 | ATTR_HOMEE_DATA = "homee_data" 79 | ATTR_NODE = "node" 80 | ATTR_VALUE = "value" 81 | 82 | # Profile Groups 83 | CLIMATE_PROFILES = [ 84 | NodeProfile.COSI_THERM_CHANNEL, 85 | NodeProfile.HEATING_SYSTEM, 86 | NodeProfile.RADIATOR_THERMOSTAT, 87 | NodeProfile.ROOM_THERMOSTAT, 88 | NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, 89 | NodeProfile.THERMOSTAT_WITH_HEATING_AND_COOLING, 90 | NodeProfile.WIFI_RADIATOR_THERMOSTAT, 91 | NodeProfile.WIFI_ROOM_THERMOSTAT, 92 | ] 93 | LIGHT_PROFILES = [ 94 | NodeProfile.DIMMABLE_COLOR_LIGHT, 95 | NodeProfile.DIMMABLE_COLOR_METERING_PLUG, 96 | NodeProfile.DIMMABLE_COLOR_TEMPERATURE_LIGHT, 97 | NodeProfile.DIMMABLE_EXTENDED_COLOR_LIGHT, 98 | NodeProfile.DIMMABLE_LIGHT, 99 | NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_SENSOR, 100 | NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_AND_PRESENCE_SENSOR, 101 | NodeProfile.DIMMABLE_LIGHT_WITH_PRESENCE_SENSOR, 102 | NodeProfile.DIMMABLE_METERING_SWITCH, 103 | NodeProfile.DIMMABLE_METERING_PLUG, 104 | NodeProfile.DIMMABLE_PLUG, 105 | NodeProfile.DIMMABLE_RGBWLIGHT, 106 | NodeProfile.DIMMABLE_SWITCH, 107 | NodeProfile.WIFI_DIMMABLE_RGBWLIGHT, 108 | NodeProfile.WIFI_DIMMABLE_LIGHT, 109 | NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, 110 | ] 111 | 112 | # Climate Presets 113 | PRESET_COMFORT = "comfort" 114 | PRESET_MANUAL = "manual" 115 | -------------------------------------------------------------------------------- /quality-scale.md: -------------------------------------------------------------------------------- 1 | ## Bronze 2 | 3 | - [x] `config-flow` - Integration needs to be able to be set up via the UI 4 | - [ ] Uses `data-description` to give context to fields 5 | - [ ] Uses `ConfigEntry.data` and `ConfigEntry.options` correctly 6 | - [x] `test-before-configure` - Test a connection in the config flow 7 | - [x] `unique-config-entry` - Don't allow the same device or service to be able to be set up twice 8 | - [x] `config-flow-test-coverage` - Full test coverage for the config flow 9 | - [X] `runtime-data` - Use ConfigEntry.runtime_data to store runtime data 10 | - [ ] `test-before-setup` - Check during integration initialization if we are able to set it up correctly 11 | - [x] `appropriate-polling` - If it's a polling integration, set an appropriate polling interval 12 | - [x] `entity-unique-id` - Entities have a unique ID 13 | - [x] `has-entity-name` - Entities use has_entity_name = True 14 | - [ ] `entity-event-setup` - Entities event setup 15 | - [ ] `dependency-transparency` - Dependency transparency 16 | - [x] `action-setup` - Service actions are registered in async_setup 17 | - [x] `common-modules` - Place common patterns in common modules 18 | - [ ] `docs-high-level-description` - The documentation includes a high-level description of the integration brand, product, or service 19 | - [ ] `docs-installation-instructions` - The documentation provides step-by-step installation instructions for the integration, including, if needed, prerequisites 20 | - [ ] `docs-removal-instructions` - The documentation provides removal instructions 21 | - [ ] `docs-actions` - The documentation describes the provided service actions that can be used 22 | - [x] `brands` - Has branding assets available for the integration 23 | 24 | ## Silver 25 | 26 | - [x] `config-entry-unloading` - Support config entry unloading 27 | - [x] `log-when-unavailable` - If internet/device/service is unavailable, log once when unavailable and once when back connected 28 | - [x] `entity-unavailable` - Mark entity unavailable if appropriate 29 | - [x] `action-exceptions` - Service actions raise exceptions when encountering failures 30 | - [x] `reauthentication-flow` - Reauthentication flow 31 | - [ ] `parallel-updates` - Set Parallel updates 32 | - [ ] `test-coverage` - Above 95% test coverage for all integration modules 33 | - [x] `integration-owner` - Has an integration owner 34 | - [ ] `docs-installation-parameters` - The documentation describes all integration installation parameters 35 | - [ ] `docs-configuration-parameters` - The documentation describes all integration configuration options 36 | 37 | ## Gold 38 | 39 | - [x] `entity-translations` - Entities have translated names 40 | - [x] `entity-device-class` - Entities use device classes where possible 41 | - [x] `devices` - The integration creates devices 42 | - [x] `entity-category` - Entities are assigned an appropriate EntityCategory 43 | - [x] `entity-disabled-by-default` - Integration disables less popular (or noisy) entities 44 | - [ ] `discovery` - Can be discovered 45 | - [ ] `stale-devices` - Clean up stale devices 46 | - [ ] `diagnostics` - Implements diagnostics 47 | - [ ] `exception-translations` - Exception messages are translatable 48 | - [ ] `icon-translations` - Icon translations 49 | - [x] `reconfiguration-flow` - Integrations should have a reconfigure flow 50 | - [ ] `dynamic-devices` - Devices added after integration setup 51 | - [ ] `discovery-update-info` - Integration uses discovery info to update network information 52 | - [ ] `repair-issues` - Repair issues and repair flows are used when user intervention is needed 53 | - [ ] `docs-use-cases` - The documentation describes use cases to illustrate how this integration can be used 54 | - [ ] `docs-supported-devices` - The documentation describes known supported / unsupported devices 55 | - [ ] `docs-supported-functions` - The documentation describes the supported functionality, including entities, and platforms 56 | - [ ] `docs-data-update` - The documentation describes how data is updated 57 | - [ ] `docs-known-limitations` - The documentation describes known limitations of the integration (not to be confused with bugs) 58 | - [ ] `docs-troubleshooting` - The documentation provides troubleshooting information 59 | - [ ] `docs-examples` - The documentation provides automation examples the user can use. 60 | 61 | ## Platinum 62 | 63 | - [ ] `async-dependency` - Dependency is async 64 | - [ ] `inject-websession` - The integration dependency supports passing in a websession 65 | - [ ] `strict-typing` - Strict typing 66 | -------------------------------------------------------------------------------- /custom_components/homee/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | """The homee alarm control panel platform.""" 2 | 3 | from pyHomee.const import AttributeType 4 | from pyHomee.model import HomeeAttribute, HomeeNode 5 | 6 | from homeassistant.components.alarm_control_panel import ( 7 | AlarmControlPanelEntity, 8 | AlarmControlPanelEntityFeature, 9 | AlarmControlPanelState, 10 | ) 11 | from homeassistant.const import Platform 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from . import HomeeConfigEntry 16 | from .entity import HomeeNodeEntity 17 | from .helpers import migrate_old_unique_ids 18 | 19 | PARALLEL_UPDATES = 0 20 | 21 | 22 | def get_features(attribute: HomeeAttribute) -> AlarmControlPanelEntityFeature: 23 | """Return the features of the alarm panel based on the atribute type.""" 24 | if attribute.type == AttributeType.HOMEE_MODE: 25 | return ( 26 | AlarmControlPanelEntityFeature.ARM_HOME 27 | | AlarmControlPanelEntityFeature.ARM_AWAY 28 | | AlarmControlPanelEntityFeature.ARM_NIGHT 29 | | AlarmControlPanelEntityFeature.ARM_VACATION 30 | ) 31 | 32 | return AlarmControlPanelEntityFeature(0) 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, 37 | config_entry: HomeeConfigEntry, 38 | async_add_devices: AddEntitiesCallback, 39 | ) -> None: 40 | """Add the homee platform for the switch component.""" 41 | 42 | devices: list[HomeeAlarmPanel] = [] 43 | for node in config_entry.runtime_data.nodes: 44 | devices.extend( 45 | HomeeAlarmPanel(node, config_entry, attribute) 46 | for attribute in node.attributes 47 | if attribute.type == AttributeType.HOMEE_MODE 48 | and attribute.editable 49 | and node.id == -1 50 | ) 51 | if devices: 52 | await migrate_old_unique_ids(hass, devices, Platform.ALARM_CONTROL_PANEL) 53 | async_add_devices(devices) 54 | 55 | 56 | class HomeeAlarmPanel(HomeeNodeEntity, AlarmControlPanelEntity): 57 | """Representation of a homee alarm control panel.""" 58 | 59 | _attr_has_entity_name = True 60 | 61 | def __init__( 62 | self, 63 | node: HomeeNode, 64 | entry: HomeeConfigEntry, 65 | alarm_panel_attribute: HomeeAttribute, 66 | ) -> None: 67 | """Initialize a homee alarm Control panel entity.""" 68 | HomeeNodeEntity.__init__(self, node, entry) 69 | self._attr_code_arm_required = False 70 | self._alarm_panel_attribute = alarm_panel_attribute 71 | self._attr_supported_features = get_features(alarm_panel_attribute) 72 | self._attr_translation_key = "homee_status" 73 | 74 | self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}-{self._alarm_panel_attribute.id}" 75 | 76 | @property 77 | def old_unique_id(self) -> str: 78 | """Return the old not so unique id of the alarm-panel entity.""" 79 | return f"{self._node.id}-alarm_panel-{self._alarm_panel_attribute.id}" 80 | 81 | @property 82 | def alarm_state(self) -> AlarmControlPanelState | None: 83 | """Return current state.""" 84 | curr_state = int(self._alarm_panel_attribute.current_value) 85 | return { 86 | 0: AlarmControlPanelState.ARMED_HOME, 87 | 1: AlarmControlPanelState.ARMED_NIGHT, 88 | 2: AlarmControlPanelState.ARMED_AWAY, 89 | 3: AlarmControlPanelState.ARMED_VACATION, 90 | }.get(curr_state) 91 | 92 | async def async_update(self) -> None: 93 | """Update entity from homee.""" 94 | homee = self._entry.runtime_data 95 | await homee.update_attribute( 96 | self._alarm_panel_attribute.node_id, self._alarm_panel_attribute.id 97 | ) 98 | 99 | async def async_alarm_disarm(self, code: str | None = None) -> None: 100 | """Send disarm command.""" 101 | # Homee does not offer a disarm command. However, we cannot get 102 | # rid of this function, so we ignore. 103 | 104 | async def async_alarm_arm_home(self, code: str | None = None) -> None: 105 | """Send arm home command.""" 106 | await self.async_set_value(self._alarm_panel_attribute, 0) 107 | 108 | async def async_alarm_arm_night(self, code: str | None = None) -> None: 109 | """Send arm night command.""" 110 | await self.async_set_value(self._alarm_panel_attribute, 1) 111 | 112 | async def async_alarm_arm_away(self, code: str | None = None) -> None: 113 | """Send arm away command.""" 114 | await self.async_set_value(self._alarm_panel_attribute, 2) 115 | 116 | async def async_alarm_arm_vacation(self, code: str | None = None) -> None: 117 | """Send arm vacation command.""" 118 | await self.async_set_value(self._alarm_panel_attribute, 3) 119 | -------------------------------------------------------------------------------- /custom_components/homee/fan.py: -------------------------------------------------------------------------------- 1 | """The Homee fan platform.""" 2 | 3 | from typing import Any 4 | 5 | from dataclasses import dataclass 6 | import math 7 | from pyHomee.const import AttributeType, NodeProfile 8 | from pyHomee.model import HomeeAttribute, HomeeNode 9 | 10 | from homeassistant.components.fan import ( 11 | FanEntity, 12 | FanEntityDescription, 13 | FanEntityFeature, 14 | ) 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.util.percentage import ( 18 | ranged_value_to_percentage, 19 | percentage_to_ranged_value, 20 | ) 21 | from homeassistant.util.scaling import int_states_in_range 22 | 23 | from . import HomeeConfigEntry 24 | from .const import DOMAIN 25 | from .entity import HomeeNodeEntity 26 | 27 | PARALLEL_UPDATES = 0 28 | 29 | 30 | @dataclass(frozen=True, kw_only=True) 31 | class HomeeFanEntityDescription(FanEntityDescription): 32 | """Describes a Homee fan entity.""" 33 | 34 | preset_modes: list[str] 35 | speed_range: tuple[int, int] 36 | 37 | 38 | async def async_setup_entry( 39 | hass: HomeAssistant, 40 | config_entry: HomeeConfigEntry, 41 | async_add_devices: AddEntitiesCallback, 42 | ) -> None: 43 | """Set up the Homee fan platform.""" 44 | 45 | async_add_devices( 46 | HomeeFan(node, config_entry) 47 | for node in config_entry.runtime_data.nodes 48 | if node.profile == NodeProfile.VENTILATION_CONTROL 49 | ) 50 | 51 | 52 | class HomeeFan(HomeeNodeEntity, FanEntity): 53 | """Representation of a Homee fan entity.""" 54 | 55 | entity_description = HomeeFanEntityDescription( 56 | key="fan", 57 | translation_key=DOMAIN, 58 | speed_range=(1, 8), 59 | preset_modes=["manual", "auto", "summer"], 60 | ) 61 | _attr_translation_key = DOMAIN 62 | _attr_name = None 63 | 64 | def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: 65 | """Initialize a Homee fan entity.""" 66 | super().__init__(node, entry) 67 | self._speed_attribute: HomeeAttribute | None = node.get_attribute_by_type( 68 | AttributeType.VENTILATION_LEVEL 69 | ) 70 | self._mode_attribute: HomeeAttribute | None = node.get_attribute_by_type( 71 | AttributeType.VENTILATION_MODE 72 | ) 73 | self._attr_supported_features = ( 74 | FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE 75 | ) 76 | self._attr_preset_modes = self.entity_description.preset_modes 77 | self._attr_speed_count = int_states_in_range( 78 | self.entity_description.speed_range 79 | ) 80 | 81 | @property 82 | def supported_features(self) -> FanEntityFeature: 83 | """Return the supported features based on preset_mode.""" 84 | features = FanEntityFeature.PRESET_MODE 85 | 86 | if self.preset_mode != "auto": 87 | features |= ( 88 | FanEntityFeature.SET_SPEED 89 | | FanEntityFeature.TURN_ON 90 | | FanEntityFeature.TURN_OFF 91 | ) 92 | 93 | return features 94 | 95 | @property 96 | def is_on(self) -> bool | None: 97 | """Return true if the entity is on.""" 98 | return self.percentage is not None and self.percentage > 0 99 | 100 | @property 101 | def percentage(self) -> int | None: 102 | """Return the current speed percentage.""" 103 | if self._speed_attribute is not None: 104 | return ranged_value_to_percentage( 105 | self.entity_description.speed_range, self._speed_attribute.current_value 106 | ) 107 | 108 | return None 109 | 110 | async def async_set_percentage(self, percentage: int) -> None: 111 | """Set the speed percentage of the fan.""" 112 | if self._speed_attribute is not None and self._speed_attribute.editable: 113 | await self.async_set_value( 114 | self._speed_attribute, 115 | math.ceil( 116 | percentage_to_ranged_value( 117 | self.entity_description.speed_range, percentage 118 | ) 119 | ), 120 | ) 121 | 122 | @property 123 | def preset_mode(self) -> str | None: 124 | """Return the mode from the float state.""" 125 | if self._mode_attribute is not None: 126 | return self.preset_modes[int(self._mode_attribute.current_value)] 127 | 128 | return None 129 | 130 | async def async_set_preset_mode(self, preset_mode: str) -> None: 131 | """Set the preset mode of the fan.""" 132 | if self._mode_attribute is not None: 133 | await self.async_set_value( 134 | self._mode_attribute, self.preset_modes.index(preset_mode) 135 | ) 136 | 137 | async def async_turn_off(self, **kwargs: Any) -> None: 138 | """Turn the fan off.""" 139 | if self._speed_attribute is not None and self._speed_attribute.editable: 140 | await self.async_set_value(self._speed_attribute, 0) 141 | 142 | async def async_turn_on( 143 | self, 144 | percentage: int | None = None, 145 | preset_mode: str | None = None, 146 | **kwargs: Any, 147 | ) -> None: 148 | """Turn the fan on.""" 149 | if preset_mode: 150 | await self.async_set_preset_mode(preset_mode) 151 | 152 | if percentage is None: 153 | percentage = ranged_value_to_percentage( 154 | self.entity_description.speed_range, self._speed_attribute.last_value 155 | ) 156 | 157 | await self.async_set_percentage(percentage) 158 | -------------------------------------------------------------------------------- /custom_components/homee/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for homee integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from pyHomee import ( 7 | HomeeAuthFailedException as HomeeAuthenticationFailedException, 8 | HomeeConnectionFailedException, 9 | Homee, 10 | ) 11 | import voluptuous as vol 12 | 13 | from homeassistant import config_entries, core, exceptions 14 | from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 15 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | AUTH_SCHEMA = vol.Schema( 22 | { 23 | vol.Required(CONF_HOST): str, 24 | vol.Required(CONF_USERNAME): str, 25 | vol.Required(CONF_PASSWORD): str, 26 | } 27 | ) 28 | 29 | 30 | async def validate_and_connect(hass: core.HomeAssistant, data) -> Homee: 31 | """Validate the user input allows us to connect.""" 32 | 33 | # TODO DATA SCHEMA validation 34 | 35 | # Create a Homee object and try to receive an access token. 36 | # This tells us if the host is reachable and if the credentials work 37 | homee = Homee(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) 38 | 39 | try: 40 | await homee.get_access_token() 41 | _LOGGER.info("Got access token for homee") 42 | except HomeeAuthenticationFailedException as exc: 43 | _LOGGER.warning("Authentication to Homee failed: %s", exc.reason) 44 | raise InvalidAuth from exc 45 | except HomeeConnectionFailedException as exc: 46 | _LOGGER.warning("Connection to Homee failed: %s", exc.__cause__) 47 | raise CannotConnect from exc 48 | 49 | hass.loop.create_task(homee.run()) 50 | _LOGGER.info("Homee task created") 51 | await homee.wait_until_connected() 52 | _LOGGER.info("Homee connected") 53 | homee.disconnect() 54 | _LOGGER.info("Homee disconnecting") 55 | await homee.wait_until_disconnected() 56 | _LOGGER.info("Homee config successfully tested") 57 | # Return homee instance 58 | return homee 59 | 60 | 61 | class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): 62 | """Handle a config flow for homee.""" 63 | 64 | VERSION = 1 65 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 66 | homee: Homee 67 | 68 | async def async_step_user( 69 | self, user_input: dict[str, Any] | None = None 70 | ) -> ConfigFlowResult: 71 | """Handle the initial user step.""" 72 | 73 | errors = {} 74 | if user_input is not None: 75 | self.homee = Homee( 76 | user_input[CONF_HOST], 77 | user_input[CONF_USERNAME], 78 | user_input[CONF_PASSWORD], 79 | ) 80 | 81 | try: 82 | await self.homee.get_access_token() 83 | except HomeeConnectionFailedException: 84 | errors["base"] = "cannot_connect" 85 | except HomeeAuthenticationFailedException: 86 | errors["base"] = "invalid_auth" 87 | except Exception: # pylint: disable=broad-except 88 | _LOGGER.exception("Unexpected exception") 89 | errors["base"] = "unknown" 90 | else: 91 | _LOGGER.info("Got access token for homee") 92 | self.hass.loop.create_task(self.homee.run()) 93 | _LOGGER.debug("Homee task created") 94 | await self.homee.wait_until_connected() 95 | _LOGGER.info("Homee connected") 96 | self.homee.disconnect() 97 | _LOGGER.debug("Homee disconnecting") 98 | await self.homee.wait_until_disconnected() 99 | _LOGGER.info("Homee config successfully tested") 100 | 101 | await self.async_set_unique_id(self.homee.settings.uid) 102 | 103 | self._abort_if_unique_id_configured() 104 | 105 | _LOGGER.info( 106 | "Created new homee entry with ID %s", self.homee.settings.uid 107 | ) 108 | 109 | return self.async_create_entry( 110 | title=f"{self.homee.settings.homee_name} ({self.homee.host})", 111 | data=user_input, 112 | ) 113 | return self.async_show_form( 114 | step_id="user", 115 | data_schema=AUTH_SCHEMA, 116 | errors=errors, 117 | ) 118 | 119 | async def async_step_reconfigure( 120 | self, user_input: dict[str, Any] | None = None 121 | ) -> ConfigFlowResult: 122 | """Handle the reconfigure flow.""" 123 | errors = {} 124 | reconfigure_entry = self._get_reconfigure_entry() 125 | new_data = reconfigure_entry.data.copy() 126 | suggested_values = { 127 | CONF_HOST: new_data.get(CONF_HOST), 128 | CONF_USERNAME: new_data.get(CONF_USERNAME), 129 | CONF_PASSWORD: new_data.get(CONF_PASSWORD), 130 | } 131 | 132 | if user_input: 133 | try: 134 | self.homee = await validate_and_connect(self.hass, user_input) 135 | except CannotConnect: 136 | errors["base"] = "cannot_connect" 137 | except InvalidAuth: 138 | errors["base"] = "invalid_auth" 139 | except Exception: # pylint: disable=broad-except 140 | _LOGGER.exception("Unexpected exception") 141 | errors["base"] = "unknown" 142 | else: 143 | await self.async_set_unique_id(self.homee.settings.uid) 144 | 145 | new_data[CONF_HOST] = user_input.get(CONF_HOST) 146 | new_data[CONF_USERNAME] = user_input.get(CONF_USERNAME) 147 | new_data[CONF_PASSWORD] = user_input.get(CONF_PASSWORD) 148 | 149 | _LOGGER.info("Updated homee entry with ID %s", self.homee.settings.uid) 150 | return self.async_update_reload_and_abort( 151 | reconfigure_entry, data=new_data 152 | ) 153 | 154 | return self.async_show_form( 155 | step_id="reconfigure", 156 | data_schema=self.add_suggested_values_to_schema( 157 | AUTH_SCHEMA, suggested_values 158 | ), 159 | errors=errors, 160 | ) 161 | 162 | 163 | class CannotConnect(exceptions.HomeAssistantError): 164 | """Error to indicate we cannot connect.""" 165 | 166 | 167 | class InvalidAuth(exceptions.HomeAssistantError): 168 | """Error to indicate there is invalid auth.""" 169 | -------------------------------------------------------------------------------- /custom_components/homee/entity.py: -------------------------------------------------------------------------------- 1 | """Base Entities for Homee integration.""" 2 | 3 | from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState 4 | from pyHomee.model import HomeeAttribute, HomeeNode 5 | 6 | from homeassistant.helpers.device_registry import DeviceInfo 7 | from homeassistant.helpers.entity import Entity 8 | 9 | from . import HomeeConfigEntry 10 | from .const import DOMAIN 11 | from .helpers import get_name_for_enum 12 | 13 | 14 | class HomeeEntity(Entity): 15 | """Represents a Homee entity consisting of a single HomeeAttribute.""" 16 | 17 | _attr_has_entity_name = True 18 | _attr_should_poll = False 19 | 20 | def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: 21 | """Initialize the wrapper using a HomeeAttribute and target entity.""" 22 | self._attribute = attribute 23 | self._attr_unique_id = ( 24 | f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" 25 | ) 26 | self._entry = entry 27 | self._attr_device_info = DeviceInfo( 28 | identifiers={ 29 | (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") 30 | } 31 | ) 32 | if attribute.name != "": 33 | self._attr_name = attribute.name 34 | 35 | self._host_connected = entry.runtime_data.connected 36 | 37 | async def async_added_to_hass(self) -> None: 38 | """Add the homee attribute entity to home assistant.""" 39 | self.async_on_remove( 40 | self._attribute.add_on_changed_listener(self._on_attribute_updated) 41 | ) 42 | self.async_on_remove( 43 | self._entry.runtime_data.add_connection_listener( 44 | self._on_connection_changed 45 | ) 46 | ) 47 | 48 | @property 49 | def extra_state_attributes(self) -> dict | None: 50 | """Add entity state.""" 51 | return {"Attribute state": self._attribute.state} 52 | 53 | @property 54 | def available(self) -> bool: 55 | """Return the availability of the underlying node.""" 56 | node = self._entry.runtime_data.get_node_by_id(self._attribute.node_id) 57 | return ( 58 | ( 59 | self._attribute.state 60 | in [AttributeState.NORMAL, AttributeState.WAITING_FOR_ACKNOWLEDGE] 61 | ) 62 | and self._host_connected 63 | and node.state == NodeState.AVAILABLE 64 | ) 65 | 66 | async def async_set_value(self, value: float) -> None: 67 | """Set an attribute value on the homee node.""" 68 | homee = self._entry.runtime_data 69 | await homee.set_value(self._attribute.node_id, self._attribute.id, value) 70 | 71 | async def async_update(self) -> None: 72 | """Update entity from homee.""" 73 | homee = self._entry.runtime_data 74 | await homee.update_attribute(self._attribute.node_id, self._attribute.id) 75 | 76 | def _on_attribute_updated(self, attribute: HomeeAttribute) -> None: 77 | self.schedule_update_ha_state() 78 | 79 | def _on_connection_changed(self, connected: bool) -> None: 80 | self._host_connected = connected 81 | self.schedule_update_ha_state() 82 | 83 | 84 | class HomeeNodeEntity(Entity): 85 | """Representation of an Entity that uses more than one HomeeAttribute.""" 86 | 87 | _attr_has_entity_name = True 88 | _attr_should_poll = False 89 | 90 | def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: 91 | """Initialize the wrapper using a HomeeNode and target entity.""" 92 | self._node = node 93 | self._attr_unique_id = f"{entry.unique_id}-{node.id}" 94 | self._entry = entry 95 | 96 | ## Homee hub itself has node-id -1 97 | if node.id == -1: 98 | self._attr_device_info = DeviceInfo( 99 | identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, 100 | ) 101 | else: 102 | self._attr_device_info = DeviceInfo( 103 | identifiers={(DOMAIN, f"{entry.unique_id}-{node.id}")}, 104 | name=node.name, 105 | model=get_name_for_enum(NodeProfile, node.profile), 106 | sw_version=self._get_software_version(), 107 | via_device=(DOMAIN, entry.runtime_data.settings.uid), 108 | ) 109 | 110 | self._host_connected = entry.runtime_data.connected 111 | 112 | async def async_added_to_hass(self) -> None: 113 | """Add the homee binary sensor device to home assistant.""" 114 | self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated)) 115 | self.async_on_remove( 116 | self._entry.runtime_data.add_connection_listener( 117 | self._on_connection_changed 118 | ) 119 | ) 120 | 121 | @property 122 | def available(self) -> bool: 123 | """Return the availability of the underlying node.""" 124 | return self._node.state == NodeState.AVAILABLE and self._host_connected 125 | 126 | async def async_update(self) -> None: 127 | """Fetch new state data for this node.""" 128 | # Base class requests the whole node, if only a single attribute is needed 129 | # the platform will overwrite this method. 130 | homee = self._entry.runtime_data 131 | await homee.update_node(self._node.id) 132 | 133 | def _get_software_version(self) -> str | None: 134 | """Return the software version of the node.""" 135 | if ( 136 | attribute := self._node.get_attribute_by_type( 137 | AttributeType.FIRMWARE_REVISION 138 | ) 139 | ) is not None: 140 | return str(attribute.get_value()) 141 | if ( 142 | attribute := self._node.get_attribute_by_type( 143 | AttributeType.SOFTWARE_REVISION 144 | ) 145 | ) is not None: 146 | return str(attribute.get_value()) 147 | 148 | return None 149 | 150 | def has_attribute(self, attribute_type: AttributeType) -> bool: 151 | """Check if an attribute of the given type exists.""" 152 | if self._node.attribute_map is None: 153 | return False 154 | 155 | return attribute_type in self._node.attribute_map 156 | 157 | async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: 158 | """Set an attribute value on the homee node.""" 159 | homee = self._entry.runtime_data 160 | await homee.set_value(attribute.node_id, attribute.id, value) 161 | 162 | def _on_node_updated(self, node: HomeeNode) -> None: 163 | self.schedule_update_ha_state() 164 | 165 | def _on_connection_changed(self, connected: bool) -> None: 166 | self._host_connected = connected 167 | self.schedule_update_ha_state() 168 | -------------------------------------------------------------------------------- /custom_components/homee/switch.py: -------------------------------------------------------------------------------- 1 | """The homee switch platform.""" 2 | 3 | import logging 4 | 5 | from pyHomee.const import AttributeType, NodeProfile 6 | from pyHomee.model import HomeeAttribute 7 | 8 | from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 9 | from homeassistant.const import EntityCategory, Platform 10 | from homeassistant.core import HomeAssistant 11 | 12 | from . import HomeeConfigEntry 13 | from .const import CLIMATE_PROFILES, LIGHT_PROFILES 14 | from .entity import HomeeEntity 15 | from .helpers import get_name_for_enum, migrate_old_unique_ids 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | PARALLEL_UPDATES = 0 20 | 21 | HOMEE_PLUG_PROFILES = [ 22 | NodeProfile.ON_OFF_PLUG, 23 | NodeProfile.METERING_PLUG, 24 | NodeProfile.DOUBLE_ON_OFF_PLUG, 25 | NodeProfile.IMPULSE_PLUG, 26 | ] 27 | 28 | HOMEE_SWITCH_ATTRIBUTES = [ 29 | AttributeType.AUTOMATIC_MODE_IMPULSE, 30 | AttributeType.BRIEFLY_OPEN_IMPULSE, 31 | AttributeType.EXTERNAL_BINARY_INPUT, 32 | AttributeType.IDENTIFICATION_MODE, 33 | AttributeType.IMPULSE, 34 | AttributeType.LIGHT_IMPULSE, 35 | AttributeType.MANUAL_OPERATION, 36 | AttributeType.MOTOR_ROTATION, 37 | AttributeType.OPEN_PARTIAL_IMPULSE, 38 | AttributeType.ON_OFF, 39 | AttributeType.PERMANENTLY_OPEN_IMPULSE, 40 | AttributeType.RESET_METER, 41 | AttributeType.RESTORE_LAST_KNOWN_STATE, 42 | AttributeType.SWITCH_TYPE, 43 | AttributeType.VENTILATE_IMPULSE, 44 | AttributeType.WATCHDOG_ON_OFF, 45 | ] 46 | 47 | DESCRIPTIVE_ATTRIBUTES = [ 48 | AttributeType.AUTOMATIC_MODE_IMPULSE, 49 | AttributeType.BRIEFLY_OPEN_IMPULSE, 50 | AttributeType.EXTERNAL_BINARY_INPUT, 51 | AttributeType.IDENTIFICATION_MODE, 52 | AttributeType.LIGHT_IMPULSE, 53 | AttributeType.MANUAL_OPERATION, 54 | AttributeType.MOTOR_ROTATION, 55 | AttributeType.OPEN_PARTIAL_IMPULSE, 56 | AttributeType.PERMANENTLY_OPEN_IMPULSE, 57 | AttributeType.RESET_METER, 58 | AttributeType.RESTORE_LAST_KNOWN_STATE, 59 | AttributeType.SWITCH_TYPE, 60 | AttributeType.VENTILATE_IMPULSE, 61 | AttributeType.WATCHDOG_ON_OFF, 62 | ] 63 | 64 | CONFIG_ATTRIBUTES = [ 65 | AttributeType.EXTERNAL_BINARY_INPUT, 66 | AttributeType.MOTOR_ROTATION, 67 | AttributeType.RESET_METER, 68 | AttributeType.RESTORE_LAST_KNOWN_STATE, 69 | AttributeType.SWITCH_TYPE, 70 | AttributeType.WATCHDOG_ON_OFF, 71 | ] 72 | DIAGNOSTIC_ATTRIBUTES = [AttributeType.IDENTIFICATION_MODE] 73 | 74 | 75 | def get_device_class( 76 | attribute: HomeeAttribute, entry: HomeeConfigEntry 77 | ) -> SwitchDeviceClass: 78 | """Determine the device class a homee node based on the node profile.""" 79 | homee = entry.runtime_data 80 | node = homee.get_node_by_id(attribute.node_id) 81 | if node.profile in HOMEE_PLUG_PROFILES and attribute.type == AttributeType.ON_OFF: 82 | return SwitchDeviceClass.OUTLET 83 | 84 | return SwitchDeviceClass.SWITCH 85 | 86 | 87 | def get_entity_category(attribute) -> EntityCategory | None: 88 | """Determine the Entity Category.""" 89 | if attribute.type in CONFIG_ATTRIBUTES: 90 | return EntityCategory.CONFIG 91 | 92 | if attribute.type in DIAGNOSTIC_ATTRIBUTES: 93 | return EntityCategory.DIAGNOSTIC 94 | 95 | return None 96 | 97 | 98 | async def async_setup_entry( 99 | hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_devices 100 | ) -> None: 101 | """Add the homee platform for the switch component.""" 102 | 103 | devices = [] 104 | for node in config_entry.runtime_data.nodes: 105 | devices.extend( 106 | HomeeSwitch(attribute, config_entry) 107 | for attribute in node.attributes 108 | if (attribute.type in HOMEE_SWITCH_ATTRIBUTES and attribute.editable) 109 | and not ( 110 | attribute.type == AttributeType.ON_OFF 111 | and node.profile in LIGHT_PROFILES 112 | ) 113 | and not ( 114 | attribute.type == AttributeType.MANUAL_OPERATION 115 | and node.profile in CLIMATE_PROFILES 116 | ) 117 | ) 118 | if devices: 119 | await migrate_old_unique_ids(hass, devices, Platform.SWITCH) 120 | async_add_devices(devices) 121 | 122 | 123 | class HomeeSwitch(HomeeEntity, SwitchEntity): 124 | """Representation of a homee switch.""" 125 | 126 | def __init__( 127 | self, 128 | attribute: HomeeAttribute, 129 | entry: HomeeConfigEntry, 130 | ) -> None: 131 | """Initialize a homee switch entity.""" 132 | super().__init__(attribute, entry) 133 | self._attr_device_class = get_device_class(attribute, entry) 134 | self._attr_entity_category = get_entity_category(attribute) 135 | 136 | @property 137 | def old_unique_id(self) -> str: 138 | """Return the old not so unique id of the switch entity.""" 139 | return f"{self._attribute.node_id}-switch-{self._attribute.id}" 140 | 141 | @property 142 | def translation_key(self) -> str | None: 143 | """Return the translation key for the switch.""" 144 | # If a switch is the main feature of a device it will get its name. 145 | translation_key = None 146 | 147 | attribute_name = get_name_for_enum(AttributeType, self._attribute.type) 148 | 149 | # If a switch type has more than one instance, 150 | # it will be named and numbered. 151 | if self._attribute.instance > 0: 152 | translation_key = f"{attribute_name.lower()}_{self._attribute.instance}" 153 | # Some switches should be named descriptive without an instance number. 154 | elif self._attribute.type in DESCRIPTIVE_ATTRIBUTES: 155 | translation_key = attribute_name.lower() 156 | 157 | if self._attribute.instance > 4: 158 | _LOGGER.error( 159 | "Did get more than 4 switches of a type," 160 | "please report at" 161 | "https://github.com/Taraman17/hacs-homee/issues" 162 | ) 163 | 164 | if translation_key is None: 165 | self._attr_name = None 166 | 167 | return translation_key 168 | 169 | @property 170 | def is_on(self) -> bool: 171 | """Return True if entity is on.""" 172 | return bool(self._attribute.current_value) 173 | 174 | @property 175 | def icon(self) -> str | None: 176 | """Return icon if different from main feature.""" 177 | if self._attribute.type == AttributeType.WATCHDOG_ON_OFF: 178 | return "mdi:dog" 179 | if self._attribute.type == AttributeType.MANUAL_OPERATION: 180 | return "mdi:hand-back-left" 181 | 182 | return None 183 | 184 | async def async_turn_on(self, **kwargs) -> None: 185 | """Turn the entity on.""" 186 | await self.async_set_value(1) 187 | 188 | async def async_turn_off(self, **kwargs) -> None: 189 | """Turn the entity off.""" 190 | await self.async_set_value(0) 191 | -------------------------------------------------------------------------------- /tests/fixtures/cover3.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "name": "Test%20Cover", 4 | "profile": 2002, 5 | "image": "default", 6 | "favorite": 0, 7 | "order": 4, 8 | "protocol": 23, 9 | "routing": 0, 10 | "state": 1, 11 | "state_changed": 1687175681, 12 | "added": 1672086680, 13 | "history": 1, 14 | "cube_type": 14, 15 | "note": "TestCoverDevice", 16 | "services": 7, 17 | "phonetic_name": "", 18 | "owner": 2, 19 | "security": 0, 20 | "attributes": [{ 21 | "id": 100, 22 | "node_id": 3, 23 | "instance": 0, 24 | "minimum": 0, 25 | "maximum": 1, 26 | "current_value": 1.0, 27 | "target_value": 1.0, 28 | "last_value": 0.0, 29 | "unit": "", 30 | "step_value": 1.0, 31 | "editable": 1, 32 | "type": 385, 33 | "state": 1, 34 | "last_changed": 1672086680, 35 | "changed_by": 1, 36 | "changed_by_id": 0, 37 | "based_on": 1, 38 | "data": "", 39 | "name": "" 40 | }, { 41 | "id": 101, 42 | "node_id": 3, 43 | "instance": 0, 44 | "minimum": 0, 45 | "maximum": 4, 46 | "current_value": 3.0, 47 | "target_value": 0.0, 48 | "last_value": 1.0, 49 | "unit": "n%2Fa", 50 | "step_value": 1.0, 51 | "editable": 1, 52 | "type": 135, 53 | "state": 1, 54 | "last_changed": 1687175680, 55 | "changed_by": 1, 56 | "changed_by_id": 0, 57 | "based_on": 1, 58 | "data": "", 59 | "name": "", 60 | "options": { 61 | "can_observe": [300], 62 | "observes": [75], 63 | "automations": ["toggle"] 64 | } 65 | }, { 66 | "id": 102, 67 | "node_id": 3, 68 | "instance": 0, 69 | "minimum": 0, 70 | "maximum": 100, 71 | "current_value": 75.0, 72 | "target_value": 0.0, 73 | "last_value": 100.0, 74 | "unit": "%25", 75 | "step_value": 0.5, 76 | "editable": 1, 77 | "type": 15, 78 | "state": 1, 79 | "last_changed": 1687175680, 80 | "changed_by": 1, 81 | "changed_by_id": 0, 82 | "based_on": 1, 83 | "data": "", 84 | "name": "", 85 | "options": { 86 | "automations": ["step"], 87 | "history": { 88 | "day": 35, 89 | "week": 5, 90 | "month": 1 91 | } 92 | } 93 | }, { 94 | "id": 103, 95 | "node_id": 3, 96 | "instance": 0, 97 | "minimum": 0, 98 | "maximum": 100, 99 | "current_value": 100.0, 100 | "target_value": 100.0, 101 | "last_value": 0.0, 102 | "unit": "%25", 103 | "step_value": 0.5, 104 | "editable": 1, 105 | "type": 349, 106 | "state": 1, 107 | "last_changed": 1672086680, 108 | "changed_by": 1, 109 | "changed_by_id": 0, 110 | "based_on": 1, 111 | "data": "", 112 | "name": "" 113 | }, { 114 | "id": 104, 115 | "node_id": 3, 116 | "instance": 0, 117 | "minimum": 0, 118 | "maximum": 130, 119 | "current_value": 129.0, 120 | "target_value": 129.0, 121 | "last_value": 1.0, 122 | "unit": "n%2Fa", 123 | "step_value": 1.0, 124 | "editable": 1, 125 | "type": 325, 126 | "state": 1, 127 | "last_changed": 1672086680, 128 | "changed_by": 1, 129 | "changed_by_id": 0, 130 | "based_on": 1, 131 | "data": "", 132 | "name": "" 133 | }, { 134 | "id": 105, 135 | "node_id": 3, 136 | "instance": 0, 137 | "minimum": 5, 138 | "maximum": 45, 139 | "current_value": 30.0, 140 | "target_value": 30.0, 141 | "last_value": 0.0, 142 | "unit": "min", 143 | "step_value": 5.0, 144 | "editable": 1, 145 | "type": 88, 146 | "state": 1, 147 | "last_changed": 1672086680, 148 | "changed_by": 1, 149 | "changed_by_id": 0, 150 | "based_on": 1, 151 | "data": "", 152 | "name": "" 153 | }, { 154 | "id": 106, 155 | "node_id": 3, 156 | "instance": 0, 157 | "minimum": 0, 158 | "maximum": 0, 159 | "current_value": 0.0, 160 | "target_value": 0.0, 161 | "last_value": 0.0, 162 | "unit": "text", 163 | "step_value": 1.0, 164 | "editable": 0, 165 | "type": 45, 166 | "state": 1, 167 | "last_changed": 1672086680, 168 | "changed_by": 1, 169 | "changed_by_id": 0, 170 | "based_on": 1, 171 | "data": "06024111153", 172 | "name": "" 173 | }, { 174 | "id": 107, 175 | "node_id": 3, 176 | "instance": 0, 177 | "minimum": 0, 178 | "maximum": 1, 179 | "current_value": 0.0, 180 | "target_value": 0.0, 181 | "last_value": 0.0, 182 | "unit": "n%2Fa", 183 | "step_value": 1.0, 184 | "editable": 1, 185 | "type": 170, 186 | "state": 1, 187 | "last_changed": 1672086680, 188 | "changed_by": 1, 189 | "changed_by_id": 0, 190 | "based_on": 1, 191 | "data": "", 192 | "name": "" 193 | }, { 194 | "id": 108, 195 | "node_id": 3, 196 | "instance": 0, 197 | "minimum": 0, 198 | "maximum": 9, 199 | "current_value": 2.0, 200 | "target_value": 2.0, 201 | "last_value": 2.0, 202 | "unit": "n%2Fa", 203 | "step_value": 1.0, 204 | "editable": 1, 205 | "type": 338, 206 | "state": 9, 207 | "last_changed": 1684668852, 208 | "changed_by": 1, 209 | "changed_by_id": 0, 210 | "based_on": 1, 211 | "data": "", 212 | "name": "" 213 | }, 214 | { 215 | "id": 109, 216 | "node_id": 3, 217 | "instance": 0, 218 | "minimum": -45, 219 | "maximum": 90, 220 | "current_value": 56.0, 221 | "target_value": 56.0, 222 | "last_value": 0.0, 223 | "unit": "%C2%B0", 224 | "step_value": 1.0, 225 | "editable": 1, 226 | "type": 113, 227 | "state": 1, 228 | "last_changed": 1678284920, 229 | "changed_by": 1, 230 | "changed_by_id": 0, 231 | "based_on": 1, 232 | "data": "", 233 | "name": "", 234 | "options": { 235 | "automations": ["step"] 236 | } 237 | } 238 | ] 239 | } -------------------------------------------------------------------------------- /tests/fixtures/cover4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "name": "Test%20Cover", 4 | "profile": 2002, 5 | "image": "default", 6 | "favorite": 0, 7 | "order": 4, 8 | "protocol": 23, 9 | "routing": 0, 10 | "state": 1, 11 | "state_changed": 1687175681, 12 | "added": 1672086680, 13 | "history": 1, 14 | "cube_type": 14, 15 | "note": "TestCoverDevice", 16 | "services": 7, 17 | "phonetic_name": "", 18 | "owner": 2, 19 | "security": 0, 20 | "attributes": [{ 21 | "id": 100, 22 | "node_id": 3, 23 | "instance": 0, 24 | "minimum": 0, 25 | "maximum": 1, 26 | "current_value": 1.0, 27 | "target_value": 1.0, 28 | "last_value": 0.0, 29 | "unit": "", 30 | "step_value": 1.0, 31 | "editable": 1, 32 | "type": 385, 33 | "state": 1, 34 | "last_changed": 1672086680, 35 | "changed_by": 1, 36 | "changed_by_id": 0, 37 | "based_on": 1, 38 | "data": "", 39 | "name": "" 40 | }, { 41 | "id": 101, 42 | "node_id": 3, 43 | "instance": 0, 44 | "minimum": 0, 45 | "maximum": 4, 46 | "current_value": 4.0, 47 | "target_value": 1.0, 48 | "last_value": 0.0, 49 | "unit": "n%2Fa", 50 | "step_value": 1.0, 51 | "editable": 1, 52 | "type": 135, 53 | "state": 1, 54 | "last_changed": 1687175680, 55 | "changed_by": 1, 56 | "changed_by_id": 0, 57 | "based_on": 1, 58 | "data": "", 59 | "name": "", 60 | "options": { 61 | "can_observe": [300], 62 | "observes": [75], 63 | "automations": ["toggle"] 64 | } 65 | }, { 66 | "id": 102, 67 | "node_id": 3, 68 | "instance": 0, 69 | "minimum": 0, 70 | "maximum": 100, 71 | "current_value": 25.0, 72 | "target_value": 100.0, 73 | "last_value": 0.0, 74 | "unit": "%25", 75 | "step_value": 0.5, 76 | "editable": 1, 77 | "type": 15, 78 | "state": 1, 79 | "last_changed": 1687175680, 80 | "changed_by": 1, 81 | "changed_by_id": 0, 82 | "based_on": 1, 83 | "data": "", 84 | "name": "", 85 | "options": { 86 | "automations": ["step"], 87 | "history": { 88 | "day": 35, 89 | "week": 5, 90 | "month": 1 91 | } 92 | } 93 | }, { 94 | "id": 103, 95 | "node_id": 3, 96 | "instance": 0, 97 | "minimum": 0, 98 | "maximum": 100, 99 | "current_value": 100.0, 100 | "target_value": 100.0, 101 | "last_value": 0.0, 102 | "unit": "%25", 103 | "step_value": 0.5, 104 | "editable": 1, 105 | "type": 349, 106 | "state": 1, 107 | "last_changed": 1672086680, 108 | "changed_by": 1, 109 | "changed_by_id": 0, 110 | "based_on": 1, 111 | "data": "", 112 | "name": "" 113 | }, { 114 | "id": 104, 115 | "node_id": 3, 116 | "instance": 0, 117 | "minimum": 0, 118 | "maximum": 130, 119 | "current_value": 129.0, 120 | "target_value": 129.0, 121 | "last_value": 1.0, 122 | "unit": "n%2Fa", 123 | "step_value": 1.0, 124 | "editable": 1, 125 | "type": 325, 126 | "state": 1, 127 | "last_changed": 1672086680, 128 | "changed_by": 1, 129 | "changed_by_id": 0, 130 | "based_on": 1, 131 | "data": "", 132 | "name": "" 133 | }, { 134 | "id": 105, 135 | "node_id": 3, 136 | "instance": 0, 137 | "minimum": 5, 138 | "maximum": 45, 139 | "current_value": 30.0, 140 | "target_value": 30.0, 141 | "last_value": 0.0, 142 | "unit": "min", 143 | "step_value": 5.0, 144 | "editable": 1, 145 | "type": 88, 146 | "state": 1, 147 | "last_changed": 1672086680, 148 | "changed_by": 1, 149 | "changed_by_id": 0, 150 | "based_on": 1, 151 | "data": "", 152 | "name": "" 153 | }, { 154 | "id": 106, 155 | "node_id": 3, 156 | "instance": 0, 157 | "minimum": 0, 158 | "maximum": 0, 159 | "current_value": 0.0, 160 | "target_value": 0.0, 161 | "last_value": 0.0, 162 | "unit": "text", 163 | "step_value": 1.0, 164 | "editable": 0, 165 | "type": 45, 166 | "state": 1, 167 | "last_changed": 1672086680, 168 | "changed_by": 1, 169 | "changed_by_id": 0, 170 | "based_on": 1, 171 | "data": "06024111153", 172 | "name": "" 173 | }, { 174 | "id": 107, 175 | "node_id": 3, 176 | "instance": 0, 177 | "minimum": 0, 178 | "maximum": 1, 179 | "current_value": 0.0, 180 | "target_value": 0.0, 181 | "last_value": 0.0, 182 | "unit": "n%2Fa", 183 | "step_value": 1.0, 184 | "editable": 1, 185 | "type": 170, 186 | "state": 1, 187 | "last_changed": 1672086680, 188 | "changed_by": 1, 189 | "changed_by_id": 0, 190 | "based_on": 1, 191 | "data": "", 192 | "name": "" 193 | }, { 194 | "id": 108, 195 | "node_id": 3, 196 | "instance": 0, 197 | "minimum": 0, 198 | "maximum": 9, 199 | "current_value": 2.0, 200 | "target_value": 2.0, 201 | "last_value": 2.0, 202 | "unit": "n%2Fa", 203 | "step_value": 1.0, 204 | "editable": 1, 205 | "type": 338, 206 | "state": 9, 207 | "last_changed": 1684668852, 208 | "changed_by": 1, 209 | "changed_by_id": 0, 210 | "based_on": 1, 211 | "data": "", 212 | "name": "" 213 | }, 214 | { 215 | "id": 109, 216 | "node_id": 3, 217 | "instance": 0, 218 | "minimum": -45, 219 | "maximum": 90, 220 | "current_value": -11.0, 221 | "target_value": 0.0, 222 | "last_value": -45.0, 223 | "unit": "%C2%B0", 224 | "step_value": 1.0, 225 | "editable": 1, 226 | "type": 113, 227 | "state": 1, 228 | "last_changed": 1678284920, 229 | "changed_by": 1, 230 | "changed_by_id": 0, 231 | "based_on": 1, 232 | "data": "", 233 | "name": "", 234 | "options": { 235 | "automations": ["step"] 236 | } 237 | } 238 | ] 239 | } -------------------------------------------------------------------------------- /tests/fixtures/cover1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "name": "Test%20Cover", 4 | "profile": 2002, 5 | "image": "default", 6 | "favorite": 0, 7 | "order": 4, 8 | "protocol": 23, 9 | "routing": 0, 10 | "state": 1, 11 | "state_changed": 1687175681, 12 | "added": 1672086680, 13 | "history": 1, 14 | "cube_type": 14, 15 | "note": "TestCoverDevice", 16 | "services": 7, 17 | "phonetic_name": "", 18 | "owner": 2, 19 | "security": 0, 20 | "attributes": [ 21 | { 22 | "id": 100, 23 | "node_id": 3, 24 | "instance": 0, 25 | "minimum": 0, 26 | "maximum": 1, 27 | "current_value": 0.0, 28 | "target_value": 0.0, 29 | "last_value": 1.0, 30 | "unit": "", 31 | "step_value": 1.0, 32 | "editable": 1, 33 | "type": 385, 34 | "state": 1, 35 | "last_changed": 1672086680, 36 | "changed_by": 1, 37 | "changed_by_id": 0, 38 | "based_on": 1, 39 | "data": "", 40 | "name": "" 41 | }, 42 | { 43 | "id": 101, 44 | "node_id": 3, 45 | "instance": 0, 46 | "minimum": 0, 47 | "maximum": 4, 48 | "current_value": 1.0, 49 | "target_value": 1.0, 50 | "last_value": 4.0, 51 | "unit": "n%2Fa", 52 | "step_value": 1.0, 53 | "editable": 1, 54 | "type": 135, 55 | "state": 1, 56 | "last_changed": 1687175680, 57 | "changed_by": 1, 58 | "changed_by_id": 0, 59 | "based_on": 1, 60 | "data": "", 61 | "name": "", 62 | "options": { 63 | "can_observe": [ 64 | 300 65 | ], 66 | "observes": [ 67 | 75 68 | ], 69 | "automations": [ 70 | "toggle" 71 | ] 72 | } 73 | }, 74 | { 75 | "id": 102, 76 | "node_id": 3, 77 | "instance": 0, 78 | "minimum": 0, 79 | "maximum": 100, 80 | "current_value": 0.0, 81 | "target_value": 0.0, 82 | "last_value": 0.0, 83 | "unit": "%25", 84 | "step_value": 0.5, 85 | "editable": 1, 86 | "type": 15, 87 | "state": 1, 88 | "last_changed": 1687175680, 89 | "changed_by": 1, 90 | "changed_by_id": 0, 91 | "based_on": 1, 92 | "data": "", 93 | "name": "", 94 | "options": { 95 | "automations": [ 96 | "step" 97 | ], 98 | "history": { 99 | "day": 35, 100 | "week": 5, 101 | "month": 1 102 | } 103 | } 104 | }, 105 | { 106 | "id": 103, 107 | "node_id": 3, 108 | "instance": 0, 109 | "minimum": 0, 110 | "maximum": 100, 111 | "current_value": 100.0, 112 | "target_value": 100.0, 113 | "last_value": 0.0, 114 | "unit": "%25", 115 | "step_value": 0.5, 116 | "editable": 1, 117 | "type": 349, 118 | "state": 1, 119 | "last_changed": 1672086680, 120 | "changed_by": 1, 121 | "changed_by_id": 0, 122 | "based_on": 1, 123 | "data": "", 124 | "name": "" 125 | }, 126 | { 127 | "id": 104, 128 | "node_id": 3, 129 | "instance": 0, 130 | "minimum": 0, 131 | "maximum": 130, 132 | "current_value": 129.0, 133 | "target_value": 129.0, 134 | "last_value": 1.0, 135 | "unit": "n%2Fa", 136 | "step_value": 1.0, 137 | "editable": 1, 138 | "type": 325, 139 | "state": 1, 140 | "last_changed": 1672086680, 141 | "changed_by": 1, 142 | "changed_by_id": 0, 143 | "based_on": 1, 144 | "data": "", 145 | "name": "" 146 | }, 147 | { 148 | "id": 105, 149 | "node_id": 3, 150 | "instance": 0, 151 | "minimum": 5, 152 | "maximum": 45, 153 | "current_value": 30.0, 154 | "target_value": 30.0, 155 | "last_value": 0.0, 156 | "unit": "min", 157 | "step_value": 5.0, 158 | "editable": 1, 159 | "type": 88, 160 | "state": 1, 161 | "last_changed": 1672086680, 162 | "changed_by": 1, 163 | "changed_by_id": 0, 164 | "based_on": 1, 165 | "data": "", 166 | "name": "" 167 | }, 168 | { 169 | "id": 106, 170 | "node_id": 3, 171 | "instance": 0, 172 | "minimum": 0, 173 | "maximum": 0, 174 | "current_value": 0.0, 175 | "target_value": 0.0, 176 | "last_value": 0.0, 177 | "unit": "text", 178 | "step_value": 1.0, 179 | "editable": 0, 180 | "type": 45, 181 | "state": 1, 182 | "last_changed": 1672086680, 183 | "changed_by": 1, 184 | "changed_by_id": 0, 185 | "based_on": 1, 186 | "data": "06024111153", 187 | "name": "" 188 | }, 189 | { 190 | "id": 107, 191 | "node_id": 3, 192 | "instance": 0, 193 | "minimum": 0, 194 | "maximum": 1, 195 | "current_value": 0.0, 196 | "target_value": 0.0, 197 | "last_value": 0.0, 198 | "unit": "n%2Fa", 199 | "step_value": 1.0, 200 | "editable": 1, 201 | "type": 170, 202 | "state": 1, 203 | "last_changed": 1672086680, 204 | "changed_by": 1, 205 | "changed_by_id": 0, 206 | "based_on": 1, 207 | "data": "", 208 | "name": "" 209 | }, 210 | { 211 | "id": 108, 212 | "node_id": 3, 213 | "instance": 0, 214 | "minimum": 0, 215 | "maximum": 9, 216 | "current_value": 2.0, 217 | "target_value": 2.0, 218 | "last_value": 2.0, 219 | "unit": "n%2Fa", 220 | "step_value": 1.0, 221 | "editable": 1, 222 | "type": 338, 223 | "state": 9, 224 | "last_changed": 1684668852, 225 | "changed_by": 1, 226 | "changed_by_id": 0, 227 | "based_on": 1, 228 | "data": "", 229 | "name": "" 230 | }, 231 | { 232 | "id": 109, 233 | "node_id": 3, 234 | "instance": 0, 235 | "minimum": -45, 236 | "maximum": 90, 237 | "current_value": -45.0, 238 | "target_value": 0.0, 239 | "last_value": -45.0, 240 | "unit": "%C2%B0", 241 | "step_value": 1.0, 242 | "editable": 1, 243 | "type": 113, 244 | "state": 1, 245 | "last_changed": 1678284920, 246 | "changed_by": 1, 247 | "changed_by_id": 0, 248 | "based_on": 1, 249 | "data": "", 250 | "name": "", 251 | "options": { 252 | "automations": [ 253 | "step" 254 | ] 255 | } 256 | } 257 | ] 258 | } -------------------------------------------------------------------------------- /tests/fixtures/cover2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 3, 3 | "name": "Test%20Cover", 4 | "profile": 2002, 5 | "image": "default", 6 | "favorite": 0, 7 | "order": 4, 8 | "protocol": 23, 9 | "routing": 0, 10 | "state": 1, 11 | "state_changed": 1687175681, 12 | "added": 1672086680, 13 | "history": 1, 14 | "cube_type": 14, 15 | "note": "TestCoverDevice", 16 | "services": 7, 17 | "phonetic_name": "", 18 | "owner": 2, 19 | "security": 0, 20 | "attributes": [ 21 | { 22 | "id": 100, 23 | "node_id": 3, 24 | "instance": 0, 25 | "minimum": 0, 26 | "maximum": 1, 27 | "current_value": 1.0, 28 | "target_value": 1.0, 29 | "last_value": 0.0, 30 | "unit": "", 31 | "step_value": 1.0, 32 | "editable": 1, 33 | "type": 385, 34 | "state": 1, 35 | "last_changed": 1672086680, 36 | "changed_by": 1, 37 | "changed_by_id": 0, 38 | "based_on": 1, 39 | "data": "", 40 | "name": "" 41 | }, 42 | { 43 | "id": 101, 44 | "node_id": 3, 45 | "instance": 0, 46 | "minimum": 0, 47 | "maximum": 4, 48 | "current_value": 1.0, 49 | "target_value": 1.0, 50 | "last_value": 0.0, 51 | "unit": "n%2Fa", 52 | "step_value": 1.0, 53 | "editable": 1, 54 | "type": 135, 55 | "state": 1, 56 | "last_changed": 1687175680, 57 | "changed_by": 1, 58 | "changed_by_id": 0, 59 | "based_on": 1, 60 | "data": "", 61 | "name": "", 62 | "options": { 63 | "can_observe": [ 64 | 300 65 | ], 66 | "observes": [ 67 | 75 68 | ], 69 | "automations": [ 70 | "toggle" 71 | ] 72 | } 73 | }, 74 | { 75 | "id": 102, 76 | "node_id": 3, 77 | "instance": 0, 78 | "minimum": 0, 79 | "maximum": 100, 80 | "current_value": 100.0, 81 | "target_value": 0.0, 82 | "last_value": 0.0, 83 | "unit": "%25", 84 | "step_value": 0.5, 85 | "editable": 1, 86 | "type": 15, 87 | "state": 1, 88 | "last_changed": 1687175680, 89 | "changed_by": 1, 90 | "changed_by_id": 0, 91 | "based_on": 1, 92 | "data": "", 93 | "name": "", 94 | "options": { 95 | "automations": [ 96 | "step" 97 | ], 98 | "history": { 99 | "day": 35, 100 | "week": 5, 101 | "month": 1 102 | } 103 | } 104 | }, 105 | { 106 | "id": 103, 107 | "node_id": 3, 108 | "instance": 0, 109 | "minimum": 0, 110 | "maximum": 100, 111 | "current_value": 100.0, 112 | "target_value": 100.0, 113 | "last_value": 0.0, 114 | "unit": "%25", 115 | "step_value": 0.5, 116 | "editable": 1, 117 | "type": 349, 118 | "state": 1, 119 | "last_changed": 1672086680, 120 | "changed_by": 1, 121 | "changed_by_id": 0, 122 | "based_on": 1, 123 | "data": "", 124 | "name": "" 125 | }, 126 | { 127 | "id": 104, 128 | "node_id": 3, 129 | "instance": 0, 130 | "minimum": 0, 131 | "maximum": 130, 132 | "current_value": 129.0, 133 | "target_value": 129.0, 134 | "last_value": 1.0, 135 | "unit": "n%2Fa", 136 | "step_value": 1.0, 137 | "editable": 1, 138 | "type": 325, 139 | "state": 1, 140 | "last_changed": 1672086680, 141 | "changed_by": 1, 142 | "changed_by_id": 0, 143 | "based_on": 1, 144 | "data": "", 145 | "name": "" 146 | }, 147 | { 148 | "id": 105, 149 | "node_id": 3, 150 | "instance": 0, 151 | "minimum": 5, 152 | "maximum": 45, 153 | "current_value": 30.0, 154 | "target_value": 30.0, 155 | "last_value": 0.0, 156 | "unit": "min", 157 | "step_value": 5.0, 158 | "editable": 1, 159 | "type": 88, 160 | "state": 1, 161 | "last_changed": 1672086680, 162 | "changed_by": 1, 163 | "changed_by_id": 0, 164 | "based_on": 1, 165 | "data": "", 166 | "name": "" 167 | }, 168 | { 169 | "id": 106, 170 | "node_id": 3, 171 | "instance": 0, 172 | "minimum": 0, 173 | "maximum": 0, 174 | "current_value": 0.0, 175 | "target_value": 0.0, 176 | "last_value": 0.0, 177 | "unit": "text", 178 | "step_value": 1.0, 179 | "editable": 0, 180 | "type": 45, 181 | "state": 1, 182 | "last_changed": 1672086680, 183 | "changed_by": 1, 184 | "changed_by_id": 0, 185 | "based_on": 1, 186 | "data": "06024111153", 187 | "name": "" 188 | }, 189 | { 190 | "id": 107, 191 | "node_id": 3, 192 | "instance": 0, 193 | "minimum": 0, 194 | "maximum": 1, 195 | "current_value": 0.0, 196 | "target_value": 0.0, 197 | "last_value": 0.0, 198 | "unit": "n%2Fa", 199 | "step_value": 1.0, 200 | "editable": 1, 201 | "type": 170, 202 | "state": 1, 203 | "last_changed": 1672086680, 204 | "changed_by": 1, 205 | "changed_by_id": 0, 206 | "based_on": 1, 207 | "data": "", 208 | "name": "" 209 | }, 210 | { 211 | "id": 108, 212 | "node_id": 3, 213 | "instance": 0, 214 | "minimum": 0, 215 | "maximum": 9, 216 | "current_value": 2.0, 217 | "target_value": 2.0, 218 | "last_value": 2.0, 219 | "unit": "n%2Fa", 220 | "step_value": 1.0, 221 | "editable": 1, 222 | "type": 338, 223 | "state": 9, 224 | "last_changed": 1684668852, 225 | "changed_by": 1, 226 | "changed_by_id": 0, 227 | "based_on": 1, 228 | "data": "", 229 | "name": "" 230 | }, 231 | { 232 | "id": 109, 233 | "node_id": 3, 234 | "instance": 0, 235 | "minimum": -45, 236 | "maximum": 90, 237 | "current_value": 90.0, 238 | "target_value": 0.0, 239 | "last_value": -45.0, 240 | "unit": "%C2%B0", 241 | "step_value": 1.0, 242 | "editable": 1, 243 | "type": 113, 244 | "state": 1, 245 | "last_changed": 1678284920, 246 | "changed_by": 1, 247 | "changed_by_id": 0, 248 | "based_on": 1, 249 | "data": "", 250 | "name": "", 251 | "options": { 252 | "automations": [ 253 | "step" 254 | ] 255 | } 256 | } 257 | ] 258 | } -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the Homee config flow.""" 2 | 3 | from unittest.mock import ANY, AsyncMock, MagicMock, patch 4 | 5 | from custom_components.homee import config_flow 6 | from custom_components.homee.config_flow import CannotConnect, InvalidAuth 7 | from custom_components.homee.const import ( 8 | CONF_ADD_HOMEE_DATA, 9 | CONF_DOOR_GROUPS, 10 | CONF_WINDOW_GROUPS, 11 | DOMAIN, 12 | ) 13 | import pytest 14 | from pytest_homeassistant_custom_component.common import MockConfigEntry 15 | 16 | from homeassistant.config_entries import SOURCE_USER 17 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.data_entry_flow import FlowResultType 20 | 21 | from .conftest import HOMEE_ID, HOMEE_IP, SCHEMA_IMPORT_ALL, TESTPASS, TESTUSER 22 | 23 | 24 | async def test_config_flow( 25 | hass: HomeAssistant, 26 | mock_homee: MagicMock, # pylint: disable=unused-argument 27 | mock_setup_entry: AsyncMock, # pylint: disable=unused-argument 28 | ) -> None: 29 | """Test the complete config flow.""" 30 | result = await hass.config_entries.flow.async_init( 31 | DOMAIN, context={"source": SOURCE_USER} 32 | ) 33 | 34 | expected = { 35 | "data_schema": config_flow.AUTH_SCHEMA, 36 | "description_placeholders": None, 37 | "errors": {}, 38 | "flow_id": ANY, 39 | "handler": DOMAIN, 40 | "step_id": "user", 41 | "type": FlowResultType.FORM, 42 | "last_step": None, 43 | "preview": None, 44 | } 45 | assert result == expected 46 | 47 | flow_id = result["flow_id"] 48 | 49 | groups_result = await hass.config_entries.flow.async_configure( 50 | flow_id, 51 | user_input={ 52 | CONF_HOST: HOMEE_IP, 53 | CONF_USERNAME: TESTUSER, 54 | CONF_PASSWORD: TESTPASS, 55 | CONF_ADD_HOMEE_DATA: False, 56 | }, 57 | ) 58 | 59 | assert groups_result["type"] == FlowResultType.FORM 60 | assert groups_result["step_id"] == "groups" 61 | # we can't directly compare the schemas, since the objects differ if manually built here. 62 | assert ( 63 | groups_result["data_schema"].schema.items.__sizeof__() 64 | == SCHEMA_IMPORT_ALL.schema.items.__sizeof__() 65 | ) 66 | 67 | final_result = await hass.config_entries.flow.async_configure( 68 | flow_id, 69 | user_input={CONF_DOOR_GROUPS: [], CONF_WINDOW_GROUPS: []}, 70 | ) 71 | 72 | expected = { 73 | "type": FlowResultType.CREATE_ENTRY, 74 | "flow_id": flow_id, 75 | "handler": DOMAIN, 76 | "data": {"host": HOMEE_IP, "username": TESTUSER, "password": TESTPASS}, 77 | "description": None, 78 | "description_placeholders": None, 79 | "context": {"source": "user", "unique_id": HOMEE_ID}, 80 | "title": f"{HOMEE_ID} ({HOMEE_IP})", 81 | "minor_version": 1, 82 | "options": { 83 | "add_homee_data": False, 84 | "groups": {"door_groups": [], "window_groups": []}, 85 | }, 86 | "version": 3, 87 | "result": ANY, 88 | } 89 | 90 | assert expected == final_result 91 | 92 | 93 | @pytest.mark.parametrize( 94 | ("side_eff", "error"), 95 | [ 96 | (InvalidAuth, {"base": "invalid_auth"}), 97 | (CannotConnect, {"base": "cannot_connect"}), 98 | ], 99 | ) 100 | async def test_config_flow_errors( 101 | hass: HomeAssistant, 102 | side_eff: Exception, 103 | error: dict[str, str], 104 | ) -> None: 105 | """Test the config flow fails as expected.""" 106 | result = await hass.config_entries.flow.async_init( 107 | DOMAIN, context={"source": SOURCE_USER} 108 | ) 109 | assert result["type"] == FlowResultType.FORM 110 | flow_id = result["flow_id"] 111 | 112 | with patch( 113 | "custom_components.homee.config_flow.validate_and_connect", side_effect=side_eff 114 | ): 115 | groups_result = await hass.config_entries.flow.async_configure( 116 | flow_id, 117 | user_input={ 118 | CONF_HOST: HOMEE_IP, 119 | CONF_USERNAME: TESTUSER, 120 | CONF_PASSWORD: TESTPASS, 121 | CONF_ADD_HOMEE_DATA: False, 122 | }, 123 | ) 124 | 125 | assert groups_result["type"] == FlowResultType.FORM 126 | assert groups_result["errors"] == error 127 | 128 | 129 | async def test_flow_already_configured( 130 | hass: HomeAssistant, 131 | mock_homee: MagicMock, # pylint: disable=unused-argument 132 | mock_config_entry: MockConfigEntry, 133 | ) -> None: 134 | """Test config flow aborts when already configured.""" 135 | mock_config_entry.add_to_hass(hass) 136 | 137 | result = await hass.config_entries.flow.async_init( 138 | DOMAIN, context={"source": SOURCE_USER} 139 | ) 140 | await hass.async_block_till_done() 141 | assert result["type"] is FlowResultType.FORM 142 | 143 | result2 = await hass.config_entries.flow.async_configure( 144 | result["flow_id"], 145 | user_input={ 146 | CONF_HOST: HOMEE_IP, 147 | CONF_USERNAME: TESTUSER, 148 | CONF_PASSWORD: TESTPASS, 149 | CONF_ADD_HOMEE_DATA: False, 150 | }, 151 | ) 152 | assert result2["type"] is FlowResultType.ABORT 153 | assert result2["reason"] == "already_configured" 154 | 155 | async def test_reconfigure_success( 156 | hass: HomeAssistant, 157 | mock_homee: MagicMock, # pylint: disable=unused-argument 158 | mock_setup_entry: AsyncMock, # pylint: disable=unused-argument 159 | mock_config_entry: MockConfigEntry, 160 | ) -> None: 161 | """Test the reconfigure flow.""" 162 | mock_config_entry.add_to_hass(hass) 163 | result = await mock_config_entry.start_reconfigure_flow(hass) 164 | 165 | expected = { 166 | "data_schema": config_flow.AUTH_SCHEMA, 167 | "description_placeholders": None, 168 | "errors": {}, 169 | "flow_id": ANY, 170 | "handler": DOMAIN, 171 | "step_id": "reconfigure", 172 | "type": FlowResultType.FORM, 173 | "last_step": None, 174 | "preview": None, 175 | } 176 | assert result == expected 177 | 178 | result2 = await hass.config_entries.flow.async_configure( 179 | result["flow_id"], 180 | user_input={ 181 | CONF_HOST: HOMEE_IP, 182 | CONF_USERNAME: TESTUSER, 183 | CONF_PASSWORD: TESTPASS, 184 | CONF_ADD_HOMEE_DATA: True, 185 | }, 186 | ) 187 | 188 | assert result2["type"] is FlowResultType.ABORT 189 | assert result2["reason"] == "reconfigure_successful" 190 | assert mock_config_entry.options == { 191 | CONF_ADD_HOMEE_DATA: True 192 | } 193 | 194 | @pytest.mark.parametrize( 195 | ("side_eff", "error"), 196 | [ 197 | (InvalidAuth, {"base": "invalid_auth"}), 198 | (CannotConnect, {"base": "cannot_connect"}), 199 | ], 200 | ) 201 | async def test_reconfigure_no_success( 202 | hass: HomeAssistant, 203 | mock_config_entry: MockConfigEntry, 204 | side_eff: Exception, 205 | error: dict[str, str], 206 | ) -> None: 207 | """Test reconfigure flow errors.""" 208 | mock_config_entry.add_to_hass(hass) 209 | result = await mock_config_entry.start_reconfigure_flow(hass) 210 | 211 | assert result["type"] == FlowResultType.FORM 212 | assert result["step_id"] == "reconfigure" 213 | 214 | with patch( 215 | "custom_components.homee.config_flow.validate_and_connect", side_effect=side_eff 216 | ): 217 | result2 = await hass.config_entries.flow.async_configure( 218 | result["flow_id"], 219 | user_input={ 220 | CONF_HOST: HOMEE_IP, 221 | CONF_USERNAME: TESTUSER, 222 | CONF_PASSWORD: TESTPASS, 223 | CONF_ADD_HOMEE_DATA: True, 224 | }, 225 | ) 226 | 227 | assert result2["type"] == FlowResultType.FORM 228 | assert result2["errors"] == error 229 | -------------------------------------------------------------------------------- /custom_components/homee/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """The homee binary sensor platform.""" 2 | 3 | from pyHomee.const import AttributeType 4 | from pyHomee.model import HomeeAttribute 5 | 6 | from homeassistant.components.binary_sensor import ( 7 | BinarySensorDeviceClass, 8 | BinarySensorEntity, 9 | BinarySensorEntityDescription, 10 | ) 11 | from homeassistant.const import EntityCategory, Platform 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | 15 | from . import HomeeConfigEntry 16 | from .entity import HomeeEntity 17 | from .helpers import migrate_old_unique_ids 18 | 19 | PARALLEL_UPDATES = 0 20 | 21 | BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = { 22 | AttributeType.BATTERY_LOW_ALARM: BinarySensorEntityDescription( 23 | key="battery_low", 24 | device_class=BinarySensorDeviceClass.BATTERY, 25 | entity_category=EntityCategory.DIAGNOSTIC, 26 | ), 27 | AttributeType.BLACKOUT_ALARM: BinarySensorEntityDescription( 28 | key="blackout_alarm", 29 | device_class=BinarySensorDeviceClass.PROBLEM, 30 | entity_category=EntityCategory.DIAGNOSTIC, 31 | ), 32 | AttributeType.CO2ALARM: BinarySensorEntityDescription( 33 | key="carbon_dioxide", device_class=BinarySensorDeviceClass.GAS 34 | ), 35 | AttributeType.FLOOD_ALARM: BinarySensorEntityDescription( 36 | key="flood", 37 | device_class=BinarySensorDeviceClass.MOISTURE, 38 | ), 39 | AttributeType.HIGH_TEMPERATURE_ALARM: BinarySensorEntityDescription( 40 | key="heat", 41 | device_class=BinarySensorDeviceClass.HEAT, 42 | entity_category=EntityCategory.DIAGNOSTIC, 43 | ), 44 | AttributeType.LEAK_ALARM: BinarySensorEntityDescription( 45 | key="leak_alarm", 46 | device_class=BinarySensorDeviceClass.PROBLEM, 47 | entity_category=EntityCategory.DIAGNOSTIC, 48 | ), 49 | AttributeType.LOAD_ALARM: BinarySensorEntityDescription( 50 | key="load_alarm", 51 | entity_category=EntityCategory.DIAGNOSTIC, 52 | ), 53 | AttributeType.LOCK_STATE: BinarySensorEntityDescription( 54 | key="lock", 55 | device_class=BinarySensorDeviceClass.LOCK, 56 | ), 57 | AttributeType.LOW_TEMPERATURE_ALARM: BinarySensorEntityDescription( 58 | key="low_temperature_alarm", 59 | device_class=BinarySensorDeviceClass.PROBLEM, 60 | entity_category=EntityCategory.DIAGNOSTIC, 61 | ), 62 | AttributeType.MALFUNCTION_ALARM: BinarySensorEntityDescription( 63 | key="malfunction", 64 | device_class=BinarySensorDeviceClass.PROBLEM, 65 | entity_category=EntityCategory.DIAGNOSTIC, 66 | ), 67 | AttributeType.MAXIMUM_ALARM: BinarySensorEntityDescription( 68 | key="maximum", 69 | device_class=BinarySensorDeviceClass.PROBLEM, 70 | entity_category=EntityCategory.DIAGNOSTIC, 71 | ), 72 | AttributeType.MINIMUM_ALARM: BinarySensorEntityDescription( 73 | key="minimum", 74 | device_class=BinarySensorDeviceClass.PROBLEM, 75 | entity_category=EntityCategory.DIAGNOSTIC, 76 | ), 77 | AttributeType.MOTION_ALARM: BinarySensorEntityDescription( 78 | key="motion", 79 | device_class=BinarySensorDeviceClass.MOTION, 80 | ), 81 | AttributeType.MOTOR_BLOCKED_ALARM: BinarySensorEntityDescription( 82 | key="motor_blocked_alarm", 83 | device_class=BinarySensorDeviceClass.PROBLEM, 84 | entity_category=EntityCategory.DIAGNOSTIC, 85 | ), 86 | AttributeType.ON_OFF: BinarySensorEntityDescription( 87 | key="plug", 88 | device_class=BinarySensorDeviceClass.PLUG, 89 | ), 90 | AttributeType.OPEN_CLOSE: BinarySensorEntityDescription( 91 | key="opening", 92 | device_class=BinarySensorDeviceClass.OPENING, 93 | ), 94 | AttributeType.OVER_CURRENT_ALARM: BinarySensorEntityDescription( 95 | key="overcurrent", 96 | device_class=BinarySensorDeviceClass.PROBLEM, 97 | entity_category=EntityCategory.DIAGNOSTIC, 98 | ), 99 | AttributeType.OVERLOAD_ALARM: BinarySensorEntityDescription( 100 | key="overload", 101 | device_class=BinarySensorDeviceClass.PROBLEM, 102 | entity_category=EntityCategory.DIAGNOSTIC, 103 | ), 104 | AttributeType.PRESENCE_ALARM: BinarySensorEntityDescription( 105 | key="motion", 106 | device_class=BinarySensorDeviceClass.MOTION, 107 | ), 108 | AttributeType.POWER_SUPPLY_ALARM: BinarySensorEntityDescription( 109 | key="power_supply_alarm", 110 | device_class=BinarySensorDeviceClass.PROBLEM, 111 | entity_category=EntityCategory.DIAGNOSTIC, 112 | ), 113 | AttributeType.RAIN_FALL: BinarySensorEntityDescription( 114 | key="rain", 115 | device_class=BinarySensorDeviceClass.MOISTURE, 116 | ), 117 | AttributeType.REPLACE_FILTER_ALARM: BinarySensorEntityDescription( 118 | key="replace_filter", 119 | device_class=BinarySensorDeviceClass.PROBLEM, 120 | entity_category=EntityCategory.DIAGNOSTIC, 121 | ), 122 | AttributeType.SMOKE_ALARM: BinarySensorEntityDescription( 123 | key="smoke", 124 | device_class=BinarySensorDeviceClass.SMOKE, 125 | ), 126 | AttributeType.STORAGE_ALARM: BinarySensorEntityDescription( 127 | key="storage_alarm", 128 | device_class=BinarySensorDeviceClass.PROBLEM, 129 | entity_category=EntityCategory.DIAGNOSTIC, 130 | ), 131 | AttributeType.SURGE_ALARM: BinarySensorEntityDescription( 132 | key="surge", 133 | device_class=BinarySensorDeviceClass.PROBLEM, 134 | entity_category=EntityCategory.DIAGNOSTIC, 135 | ), 136 | AttributeType.TAMPER_ALARM: BinarySensorEntityDescription( 137 | key="tamper", 138 | device_class=BinarySensorDeviceClass.TAMPER, 139 | entity_category=EntityCategory.DIAGNOSTIC, 140 | ), 141 | AttributeType.VOLTAGE_DROP_ALARM: BinarySensorEntityDescription( 142 | key="voltage_drop", 143 | device_class=BinarySensorDeviceClass.PROBLEM, 144 | entity_category=EntityCategory.DIAGNOSTIC, 145 | ), 146 | AttributeType.WATER_ALARM: BinarySensorEntityDescription( 147 | key="water_alarm", 148 | device_class=BinarySensorDeviceClass.PROBLEM, 149 | entity_category=EntityCategory.DIAGNOSTIC, 150 | ), 151 | } 152 | 153 | 154 | async def async_setup_entry( 155 | hass: HomeAssistant, 156 | config_entry: HomeeConfigEntry, 157 | async_add_devices: AddEntitiesCallback, 158 | ) -> None: 159 | """Add the homee platform for the binary sensor integration.""" 160 | 161 | devices: list[HomeeBinarySensor] = [] 162 | for node in config_entry.runtime_data.nodes: 163 | devices.extend( 164 | HomeeBinarySensor( 165 | attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] 166 | ) 167 | for attribute in node.attributes 168 | if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable 169 | ) 170 | if devices: 171 | await migrate_old_unique_ids(hass, devices, Platform.BINARY_SENSOR) 172 | async_add_devices(devices) 173 | 174 | 175 | class HomeeBinarySensor(HomeeEntity, BinarySensorEntity): 176 | """Representation of a homee binary sensor device.""" 177 | 178 | def __init__( 179 | self, 180 | attribute: HomeeAttribute, 181 | entry: HomeeConfigEntry, 182 | description: BinarySensorEntityDescription, 183 | ) -> None: 184 | """Initialize a homee binary sensor entity.""" 185 | super().__init__(attribute, entry) 186 | 187 | self.entity_description = description 188 | self._attr_translation_key = description.key 189 | 190 | @property 191 | def old_unique_id(self) -> str: 192 | """Return the old not so unique id of the binary-sensor entity.""" 193 | return f"{self._attribute.node_id}-binary_sensor-{self._attribute.id}" 194 | 195 | @property 196 | def is_on(self) -> bool: 197 | """Return true if the binary sensor is on.""" 198 | return bool(self._attribute.get_value()) 199 | -------------------------------------------------------------------------------- /custom_components/homee/climate.py: -------------------------------------------------------------------------------- 1 | """The Homee climate platform.""" 2 | 3 | from typing import Any 4 | 5 | from pyHomee.const import AttributeType, NodeProfile 6 | from pyHomee.model import HomeeNode 7 | 8 | from homeassistant.components.climate import ( 9 | ATTR_TEMPERATURE, 10 | PRESET_BOOST, 11 | PRESET_ECO, 12 | PRESET_NONE, 13 | ClimateEntity, 14 | ClimateEntityFeature, 15 | HVACAction, 16 | HVACMode, 17 | ) 18 | from homeassistant.const import Platform 19 | from homeassistant.core import HomeAssistant 20 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 21 | 22 | from . import HomeeConfigEntry 23 | from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL 24 | from .entity import HomeeNodeEntity 25 | from .helpers import migrate_old_unique_ids 26 | 27 | PARALLEL_UPDATES = 0 28 | 29 | ROOM_THERMOSTATS = { 30 | NodeProfile.ROOM_THERMOSTAT, 31 | NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, 32 | NodeProfile.WIFI_ROOM_THERMOSTAT, 33 | } 34 | 35 | 36 | async def async_setup_entry( 37 | hass: HomeAssistant, 38 | config_entry: HomeeConfigEntry, 39 | async_add_devices: AddConfigEntryEntitiesCallback, 40 | ) -> None: 41 | """Add the homee platform for the climate integration.""" 42 | 43 | devices = [ 44 | HomeeClimate(node, config_entry) 45 | for node in config_entry.runtime_data.nodes 46 | if node.profile in CLIMATE_PROFILES 47 | ] 48 | if devices: 49 | await migrate_old_unique_ids(hass, devices, Platform.CLIMATE) 50 | async_add_devices(devices) 51 | 52 | 53 | class HomeeClimate(HomeeNodeEntity, ClimateEntity): 54 | """Representation of a Homee climate entity.""" 55 | 56 | _attr_name = None 57 | _attr_translation_key = DOMAIN 58 | 59 | def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: 60 | """Initialize a Homee climate entity.""" 61 | super().__init__(node, entry) 62 | 63 | ( 64 | self._attr_supported_features, 65 | self._attr_hvac_modes, 66 | self._attr_preset_modes, 67 | ) = get_climate_features(self._node) 68 | 69 | self._target_temp = self._node.get_attribute_by_type( 70 | AttributeType.TARGET_TEMPERATURE 71 | ) 72 | assert self._target_temp is not None 73 | self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) 74 | self._attr_target_temperature_step = self._target_temp.step_value 75 | self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" 76 | 77 | self._heating_mode = self._node.get_attribute_by_type( 78 | AttributeType.HEATING_MODE 79 | ) 80 | self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) 81 | self._valve_position = self._node.get_attribute_by_type( 82 | AttributeType.CURRENT_VALVE_POSITION 83 | ) 84 | 85 | @property 86 | def old_unique_id(self) -> str: 87 | """Return the old not so unique id of the climate entity.""" 88 | return f"{self._node.id}-climate" 89 | 90 | @property 91 | def hvac_mode(self) -> HVACMode: 92 | """Return the hvac operation mode.""" 93 | if ClimateEntityFeature.TURN_OFF in self.supported_features and ( 94 | self._heating_mode is not None 95 | ): 96 | if self._heating_mode.current_value == 0 + self._heating_mode.minimum: 97 | return HVACMode.OFF 98 | 99 | return HVACMode.HEAT 100 | 101 | @property 102 | def hvac_action(self) -> HVACAction: 103 | """Return the hvac action.""" 104 | if ( 105 | self._heating_mode is not None 106 | and self._heating_mode.current_value == 0 + self._heating_mode.minimum 107 | ): 108 | return HVACAction.OFF 109 | 110 | if ( 111 | self._valve_position is not None and self._valve_position.current_value == 0 112 | ) or ( 113 | self._temperature is not None 114 | and self._temperature.current_value >= self.target_temperature 115 | ): 116 | return HVACAction.IDLE 117 | 118 | return HVACAction.HEATING 119 | 120 | @property 121 | def preset_mode(self) -> str: 122 | """Return the present preset mode.""" 123 | if ( 124 | ClimateEntityFeature.PRESET_MODE in self.supported_features 125 | and self._heating_mode is not None 126 | and self._heating_mode.current_value > 0 + self._heating_mode.minimum 127 | ): 128 | assert self._attr_preset_modes is not None 129 | return self._attr_preset_modes[ 130 | int(self._heating_mode.current_value - self._heating_mode.minimum) - 1 131 | ] 132 | 133 | return PRESET_NONE 134 | 135 | @property 136 | def current_temperature(self) -> float | None: 137 | """Return the current temperature.""" 138 | if self._temperature is not None: 139 | return self._temperature.current_value 140 | return None 141 | 142 | @property 143 | def target_temperature(self) -> float: 144 | """Return the temperature we try to reach.""" 145 | assert self._target_temp is not None 146 | return self._target_temp.current_value 147 | 148 | @property 149 | def min_temp(self) -> float: 150 | """Return the lowest settable target temperature.""" 151 | assert self._target_temp is not None 152 | return self._target_temp.minimum 153 | 154 | @property 155 | def max_temp(self) -> float: 156 | """Return the lowest settable target temperature.""" 157 | assert self._target_temp is not None 158 | return self._target_temp.maximum 159 | 160 | async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 161 | """Set new target hvac mode.""" 162 | # Currently only HEAT and OFF are supported. 163 | assert self._heating_mode is not None 164 | await self.async_set_value( 165 | self._heating_mode, 166 | float(hvac_mode == HVACMode.HEAT) + self._heating_mode.minimum, 167 | ) 168 | 169 | async def async_set_preset_mode(self, preset_mode: str) -> None: 170 | """Set new target preset mode.""" 171 | assert self._heating_mode is not None and self._attr_preset_modes is not None 172 | await self.async_set_value( 173 | self._heating_mode, 174 | self._attr_preset_modes.index(preset_mode) + self._heating_mode.minimum + 1, 175 | ) 176 | 177 | async def async_set_temperature(self, **kwargs: Any) -> None: 178 | """Set new target temperature.""" 179 | assert self._target_temp is not None 180 | if ATTR_TEMPERATURE in kwargs: 181 | await self.async_set_value( 182 | self._target_temp, kwargs[ATTR_TEMPERATURE] 183 | ) 184 | 185 | async def async_turn_on(self) -> None: 186 | """Turn the entity on.""" 187 | assert self._heating_mode is not None 188 | await self.async_set_value( 189 | self._heating_mode, 1 + self._heating_mode.minimum 190 | ) 191 | 192 | async def async_turn_off(self) -> None: 193 | """Turn the entity on.""" 194 | assert self._heating_mode is not None 195 | await self.async_set_value( 196 | self._heating_mode, 0 + self._heating_mode.minimum 197 | ) 198 | 199 | 200 | def get_climate_features( 201 | node: HomeeNode, 202 | ) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: 203 | """Determine supported climate features of a node based on the available attributes.""" 204 | features = ClimateEntityFeature.TARGET_TEMPERATURE 205 | hvac_modes = [HVACMode.HEAT] 206 | preset_modes: list[str] = [] 207 | 208 | if ( 209 | attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) 210 | ) is not None: 211 | features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF 212 | hvac_modes.append(HVACMode.OFF) 213 | 214 | if attribute.maximum > 1: 215 | # Node supports more modes than off and heating. 216 | features |= ClimateEntityFeature.PRESET_MODE 217 | if attribute.maximum < 5: 218 | preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) 219 | else: 220 | preset_modes.extend([PRESET_ECO]) 221 | 222 | if len(preset_modes) > 0: 223 | preset_modes.insert(0, PRESET_NONE) 224 | return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) 225 | -------------------------------------------------------------------------------- /custom_components/homee/light.py: -------------------------------------------------------------------------------- 1 | """The Homee light platform.""" 2 | 3 | from typing import Any 4 | 5 | from pyHomee.const import AttributeType 6 | from pyHomee.model import HomeeAttribute, HomeeNode 7 | 8 | from homeassistant.components.light import ( 9 | ATTR_BRIGHTNESS, 10 | ATTR_COLOR_TEMP_KELVIN, 11 | ATTR_HS_COLOR, 12 | ColorMode, 13 | LightEntity, 14 | ) 15 | from homeassistant.const import Platform 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 18 | from homeassistant.util.color import ( 19 | brightness_to_value, 20 | color_hs_to_RGB, 21 | color_RGB_to_hs, 22 | value_to_brightness, 23 | ) 24 | 25 | from . import HomeeConfigEntry 26 | from .const import LIGHT_PROFILES 27 | from .entity import HomeeNodeEntity 28 | from .helpers import migrate_old_unique_ids 29 | 30 | LIGHT_ATTRIBUTES = [ 31 | AttributeType.COLOR, 32 | AttributeType.COLOR_MODE, 33 | AttributeType.COLOR_TEMPERATURE, 34 | AttributeType.DIMMING_LEVEL, 35 | ] 36 | 37 | PARALLEL_UPDATES = 0 38 | 39 | 40 | def is_light_node(node: HomeeNode) -> bool: 41 | """Determine if a node is controllable as a homee light based on its profile and attributes.""" 42 | assert node.attribute_map is not None 43 | return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map 44 | 45 | 46 | def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode: 47 | """Determine the color mode from the supported modes.""" 48 | if ColorMode.HS in supported_modes: 49 | return ColorMode.HS 50 | if ColorMode.COLOR_TEMP in supported_modes: 51 | return ColorMode.COLOR_TEMP 52 | if ColorMode.BRIGHTNESS in supported_modes: 53 | return ColorMode.BRIGHTNESS 54 | 55 | return ColorMode.ONOFF 56 | 57 | 58 | def get_light_attribute_sets( 59 | node: HomeeNode, 60 | ) -> list[dict[AttributeType, HomeeAttribute]]: 61 | """Return the lights with their attributes as found in the node.""" 62 | lights: list[dict[AttributeType, HomeeAttribute]] = [] 63 | on_off_attributes = [ 64 | i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable 65 | ] 66 | for a in on_off_attributes: 67 | attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a} 68 | for attribute in node.attributes: 69 | if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES: 70 | attribute_dict[attribute.type] = attribute 71 | lights.append(attribute_dict) 72 | 73 | return lights 74 | 75 | 76 | def rgb_list_to_decimal(color: tuple[int, int, int]) -> int: 77 | """Convert an rgb color from list to decimal representation.""" 78 | return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2])) 79 | 80 | 81 | def decimal_to_rgb_list(color: float) -> list[int]: 82 | """Convert an rgb color from decimal to list representation.""" 83 | return [ 84 | (int(color) & 0xFF0000) >> 16, 85 | (int(color) & 0x00FF00) >> 8, 86 | (int(color) & 0x0000FF), 87 | ] 88 | 89 | 90 | async def async_setup_entry( 91 | hass: HomeAssistant, 92 | config_entry: HomeeConfigEntry, 93 | async_add_devices: AddConfigEntryEntitiesCallback, 94 | ) -> None: 95 | """Add the homee platform for the light integration.""" 96 | 97 | devices: list[HomeeLight] = [] 98 | for node in config_entry.runtime_data.nodes: 99 | if is_light_node(node): 100 | light_set = get_light_attribute_sets(node) 101 | devices.extend(HomeeLight(node, light, config_entry) for light in light_set) 102 | 103 | if devices: 104 | await migrate_old_unique_ids(hass, devices, Platform.LIGHT) 105 | async_add_devices(devices) 106 | 107 | 108 | class HomeeLight(HomeeNodeEntity, LightEntity): 109 | """Representation of a Homee light.""" 110 | 111 | def __init__( 112 | self, 113 | node: HomeeNode, 114 | light: dict[AttributeType, HomeeAttribute], 115 | entry: HomeeConfigEntry, 116 | ) -> None: 117 | """Initialize a Homee light.""" 118 | super().__init__(node, entry) 119 | 120 | self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF] 121 | self._dimmer_attr: HomeeAttribute | None = light.get( 122 | AttributeType.DIMMING_LEVEL 123 | ) 124 | self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR) 125 | self._temp_attr: HomeeAttribute | None = light.get( 126 | AttributeType.COLOR_TEMPERATURE 127 | ) 128 | self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE) 129 | 130 | self._attr_supported_color_modes = self._get_supported_color_modes() 131 | self._attr_color_mode = get_color_mode(self._attr_supported_color_modes) 132 | 133 | if self._temp_attr is not None: 134 | self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum) 135 | self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum) 136 | 137 | if self._on_off_attr.instance > 0: 138 | self._attr_translation_key = "light_instance" 139 | self._attr_translation_placeholders = { 140 | "instance": str(self._on_off_attr.instance) 141 | } 142 | else: 143 | # If a device has only one light, it will get its name. 144 | self._attr_name = None 145 | self._attr_unique_id = ( 146 | f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}" 147 | ) 148 | 149 | @property 150 | def old_unique_id(self) -> str: 151 | """Return the old not so unique id of the light entity.""" 152 | assert self._on_off_attr is not None 153 | return f"{self._node.id}-light-{self._on_off_attr.id}" 154 | 155 | @property 156 | def brightness(self) -> int: 157 | """Return the brightness of the light.""" 158 | assert self._dimmer_attr is not None 159 | return value_to_brightness( 160 | (self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum), 161 | self._dimmer_attr.current_value, 162 | ) 163 | 164 | @property 165 | def hs_color(self) -> tuple[float, float] | None: 166 | """Return the color of the light.""" 167 | assert self._col_attr is not None 168 | rgb = decimal_to_rgb_list(self._col_attr.current_value) 169 | return color_RGB_to_hs(*rgb) 170 | 171 | @property 172 | def color_temp_kelvin(self) -> int: 173 | """Return the color temperature of the light.""" 174 | assert self._temp_attr is not None 175 | return int(self._temp_attr.current_value) 176 | 177 | @property 178 | def is_on(self) -> bool: 179 | """Return true if light is on.""" 180 | return bool(self._on_off_attr.current_value) 181 | 182 | async def async_turn_on(self, **kwargs: Any) -> None: 183 | """Instruct the light to turn on.""" 184 | if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None: 185 | target_value = round( 186 | brightness_to_value( 187 | (self._dimmer_attr.minimum, self._dimmer_attr.maximum), 188 | kwargs[ATTR_BRIGHTNESS], 189 | ) 190 | ) 191 | await self.async_set_value(self._dimmer_attr, target_value) 192 | else: 193 | # If no brightness value is given, just turn on. 194 | await self.async_set_value(self._on_off_attr, 1) 195 | 196 | if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None: 197 | await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]) 198 | if ATTR_HS_COLOR in kwargs: 199 | color = kwargs[ATTR_HS_COLOR] 200 | if self._col_attr is not None: 201 | await self.async_set_value( 202 | self._col_attr, 203 | rgb_list_to_decimal(color_hs_to_RGB(*color)), 204 | ) 205 | 206 | async def async_turn_off(self, **kwargs: Any) -> None: 207 | """Instruct the light to turn off.""" 208 | await self.async_set_value(self._on_off_attr, 0) 209 | 210 | def _get_supported_color_modes(self) -> set[ColorMode]: 211 | """Determine the supported color modes from the available attributes.""" 212 | color_modes: set[ColorMode] = set() 213 | 214 | if self._temp_attr is not None and self._temp_attr.editable: 215 | color_modes.add(ColorMode.COLOR_TEMP) 216 | if self._col_attr is not None: 217 | color_modes.add(ColorMode.HS) 218 | 219 | # If no other color modes are available, set one of those. 220 | if len(color_modes) == 0: 221 | if self._dimmer_attr is not None: 222 | color_modes.add(ColorMode.BRIGHTNESS) 223 | else: 224 | color_modes.add(ColorMode.ONOFF) 225 | 226 | return color_modes 227 | -------------------------------------------------------------------------------- /custom_components/homee/number.py: -------------------------------------------------------------------------------- 1 | """The homee number platform.""" 2 | 3 | from pyHomee.const import AttributeType 4 | from pyHomee.model import HomeeAttribute 5 | 6 | from homeassistant.components.number import NumberDeviceClass, NumberEntity 7 | from homeassistant.const import EntityCategory, Platform 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.exceptions import ServiceValidationError 10 | 11 | from . import HomeeConfigEntry 12 | from .const import DOMAIN 13 | from .entity import HomeeEntity 14 | from .helpers import migrate_old_unique_ids 15 | 16 | PARALLEL_UPDATES = 0 17 | 18 | NUMBER_ATTRIBUTES = { 19 | AttributeType.BUTTON_BRIGHTNESS_ACTIVE, 20 | AttributeType.BUTTON_BRIGHTNESS_DIMMED, 21 | AttributeType.DISPLAY_BRIGHTNESS_ACTIVE, 22 | AttributeType.DISPLAY_BRIGHTNESS_DIMMED, 23 | AttributeType.CURRENT_VALVE_POSITION, 24 | AttributeType.DOWN_POSITION, 25 | AttributeType.DOWN_SLAT_POSITION, 26 | AttributeType.DOWN_TIME, 27 | AttributeType.ENDPOSITION_CONFIGURATION, 28 | AttributeType.EXTERNAL_TEMPERATURE_OFFSET, 29 | AttributeType.FLOOR_TEMPERATURE_OFFSET, 30 | AttributeType.MOTION_ALARM_CANCELATION_DELAY, 31 | AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY, 32 | AttributeType.POLLING_INTERVAL, 33 | AttributeType.SHUTTER_SLAT_TIME, 34 | AttributeType.SLAT_MAX_ANGLE, 35 | AttributeType.SLAT_MIN_ANGLE, 36 | AttributeType.SLAT_STEPS, 37 | AttributeType.TEMPERATURE_OFFSET, 38 | AttributeType.TEMPERATURE_OFFSET, 39 | AttributeType.UP_TIME, 40 | AttributeType.WAKE_UP_INTERVAL, 41 | AttributeType.WIND_MONITORING_STATE, 42 | } 43 | 44 | 45 | def get_device_properties(attribute: HomeeAttribute): 46 | """Determinde the device properties based on the attribute.""" 47 | device_class = None 48 | translation_key = None 49 | entity_category = None 50 | 51 | if attribute.type == AttributeType.CURRENT_VALVE_POSITION: 52 | translation_key = "number_valve_position" 53 | 54 | if attribute.type == AttributeType.BUTTON_BRIGHTNESS_ACTIVE: 55 | translation_key = "number_button_brightness_active" 56 | entity_category = EntityCategory.CONFIG 57 | 58 | if attribute.type == AttributeType.BUTTON_BRIGHTNESS_DIMMED: 59 | translation_key = "number_button_brightness_dimmed" 60 | entity_category = EntityCategory.CONFIG 61 | 62 | if attribute.type == AttributeType.DISPLAY_BRIGHTNESS_ACTIVE: 63 | translation_key = "number_display_brightness_active" 64 | entity_category = EntityCategory.CONFIG 65 | 66 | if attribute.type == AttributeType.DISPLAY_BRIGHTNESS_DIMMED: 67 | translation_key = "number_display_brightness_dimmed" 68 | entity_category = EntityCategory.CONFIG 69 | 70 | if attribute.type == AttributeType.DOWN_POSITION: 71 | translation_key = "number_down_position" 72 | entity_category = EntityCategory.CONFIG 73 | 74 | if attribute.type == AttributeType.DOWN_SLAT_POSITION: 75 | translation_key = "number_down_slat_position" 76 | entity_category = EntityCategory.CONFIG 77 | 78 | if attribute.type == AttributeType.DOWN_TIME: 79 | device_class = NumberDeviceClass.DURATION 80 | translation_key = "number_down_time" 81 | entity_category = EntityCategory.CONFIG 82 | 83 | if attribute.type == AttributeType.ENDPOSITION_CONFIGURATION: 84 | translation_key = "number_endposition_configuration" 85 | entity_category = EntityCategory.CONFIG 86 | 87 | if attribute.type == AttributeType.EXTERNAL_TEMPERATURE_OFFSET: 88 | translation_key = "number_external_temperature_offset" 89 | entity_category = EntityCategory.CONFIG 90 | 91 | if attribute.type == AttributeType.FLOOR_TEMPERATURE_OFFSET: 92 | translation_key = "number_floor_temperature_offset" 93 | entity_category = EntityCategory.CONFIG 94 | 95 | if attribute.type == AttributeType.MOTION_ALARM_CANCELATION_DELAY: 96 | translation_key = "number_motion_alarm_cancelation_delay" 97 | entity_category = EntityCategory.CONFIG 98 | 99 | if attribute.type == AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: 100 | translation_key = "number_open_window_detection_sensibility" 101 | entity_category = EntityCategory.CONFIG 102 | 103 | if attribute.type == AttributeType.POLLING_INTERVAL: 104 | translation_key = "number_polling_interval" 105 | entity_category = EntityCategory.CONFIG 106 | 107 | if attribute.type == AttributeType.SHUTTER_SLAT_TIME: 108 | device_class = NumberDeviceClass.DURATION 109 | translation_key = "number_shutter_slat_time" 110 | entity_category = EntityCategory.CONFIG 111 | 112 | if attribute.type == AttributeType.SLAT_MAX_ANGLE: 113 | translation_key = "number_slat_max_angle" 114 | entity_category = EntityCategory.CONFIG 115 | 116 | if attribute.type == AttributeType.SLAT_MIN_ANGLE: 117 | translation_key = "number_slat_min_angle" 118 | entity_category = EntityCategory.CONFIG 119 | 120 | if attribute.type == AttributeType.SLAT_STEPS: 121 | translation_key = "number_slat_steps" 122 | entity_category = EntityCategory.CONFIG 123 | 124 | if attribute.type == AttributeType.TEMPERATURE_OFFSET: 125 | device_class = NumberDeviceClass.TEMPERATURE 126 | translation_key = "number_temperature_offset" 127 | entity_category = EntityCategory.CONFIG 128 | 129 | if attribute.type == AttributeType.TEMPERATURE_REPORT_INTERVAL: 130 | device_class = NumberDeviceClass.DURATION 131 | translation_key = "number_temperature_report_interval" 132 | entity_category = EntityCategory.CONFIG 133 | 134 | if attribute.type == AttributeType.UP_TIME: 135 | device_class = NumberDeviceClass.DURATION 136 | translation_key = "number_up_time" 137 | entity_category = EntityCategory.CONFIG 138 | 139 | if attribute.type == AttributeType.WAKE_UP_INTERVAL: 140 | translation_key = "number_wake_up_interval" 141 | entity_category = EntityCategory.CONFIG 142 | 143 | if attribute.type == AttributeType.WIND_MONITORING_STATE: 144 | translation_key = "number_wind_monitoring_state" 145 | entity_category = EntityCategory.CONFIG 146 | 147 | return (device_class, translation_key, entity_category) 148 | 149 | 150 | async def async_setup_entry( 151 | hass: HomeAssistant, config_entry: HomeeConfigEntry, async_add_devices 152 | ): 153 | """Add the homee platform for the number components.""" 154 | 155 | devices = [] 156 | for node in config_entry.runtime_data.nodes: 157 | devices.extend( 158 | HomeeNumber(attribute, config_entry) 159 | for attribute in node.attributes 160 | if attribute.type in NUMBER_ATTRIBUTES and attribute.data != "fixed_value" 161 | ) 162 | if devices: 163 | await migrate_old_unique_ids(hass, devices, Platform.NUMBER) 164 | async_add_devices(devices) 165 | 166 | 167 | class HomeeNumber(HomeeEntity, NumberEntity): 168 | """Representation of a homee number.""" 169 | 170 | def __init__( 171 | self, 172 | attribute: HomeeAttribute, 173 | entry: HomeeConfigEntry, 174 | ) -> None: 175 | """Initialize a homee number entity.""" 176 | super().__init__(attribute, entry) 177 | ( 178 | self._attr_device_class, 179 | self._attr_translation_key, 180 | self._attr_entity_category, 181 | ) = get_device_properties(attribute) 182 | self._attr_native_min_value = attribute.minimum 183 | self._attr_native_max_value = attribute.maximum 184 | self._attr_native_step = attribute.step_value 185 | 186 | if self.translation_key is None: 187 | self._attr_name = None 188 | 189 | @property 190 | def old_unique_id(self) -> str: 191 | """Return the old not so unique id of the number entity.""" 192 | return f"{self._attribute.node_id}-number-{self._attribute.id}" 193 | 194 | @property 195 | def available(self) -> bool: 196 | """Return the availability of the underlying node.""" 197 | return super().available and self._attribute.editable 198 | 199 | @property 200 | def native_value(self) -> int: 201 | """Return the native value of the sensor.""" 202 | # TODO: If HA supports klx as unit, remove. 203 | if self._attribute.unit == "klx": 204 | return int(self._attribute.current_value) * 1000 205 | 206 | return int(self._attribute.current_value) 207 | 208 | @property 209 | def native_unit_of_measurement(self) -> str: 210 | """Return the native unit of the number entity.""" 211 | if self._attribute.unit == "n/a": 212 | return "" 213 | 214 | # TODO: If HA supports klx as unit, remove. 215 | if self._attribute.unit == "klx": 216 | return "lx" 217 | 218 | return self._attribute.unit 219 | 220 | async def async_set_native_value(self, value: float) -> None: 221 | """Update the current value.""" 222 | if self._attribute.editable: 223 | await self._entry.runtime_data.set_value( 224 | self._attribute.node_id, self._attribute.id, value 225 | ) 226 | else: 227 | raise ServiceValidationError( 228 | translation_domain=DOMAIN, 229 | translation_key="not_editable", 230 | translation_placeholders={"entity": str(self.name)}, 231 | ) 232 | -------------------------------------------------------------------------------- /custom_components/homee/__init__.py: -------------------------------------------------------------------------------- 1 | """The homee integration.""" 2 | 3 | import logging 4 | 5 | from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException 6 | from pyHomee.const import NodeProfile 7 | import voluptuous as vol 8 | 9 | from homeassistant.config_entries import ConfigEntry, ConfigEntryState 10 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform 11 | from homeassistant.core import HomeAssistant, ServiceCall, callback 12 | from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError 13 | from homeassistant.helpers import ( 14 | config_validation as cv, 15 | device_registry as dr, 16 | entity_registry as er, 17 | ) 18 | from homeassistant.helpers.typing import ConfigType 19 | 20 | from .const import ( 21 | ATTR_ATTRIBUTE, 22 | ATTR_CONFIG_ENTRY_ID, 23 | ATTR_NODE, 24 | ATTR_VALUE, 25 | DOMAIN, 26 | SERVICE_SET_VALUE, 27 | ) 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | PLATFORMS = [ 32 | Platform.ALARM_CONTROL_PANEL, 33 | Platform.BINARY_SENSOR, 34 | Platform.CLIMATE, 35 | Platform.COVER, 36 | Platform.EVENT, 37 | Platform.FAN, 38 | Platform.LIGHT, 39 | Platform.LOCK, 40 | Platform.NUMBER, 41 | Platform.SELECT, 42 | Platform.SENSOR, 43 | Platform.SIREN, 44 | Platform.SWITCH, 45 | ] 46 | 47 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 48 | 49 | type HomeeConfigEntry = ConfigEntry[Homee] 50 | 51 | 52 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 53 | """Set up the homee component.""" 54 | if DOMAIN not in hass.data: 55 | hass.data[DOMAIN] = {} 56 | 57 | # Register the set_value service that can be used 58 | # for debugging and custom automations. 59 | SET_VALUE_SCHEMA = vol.Schema( 60 | { 61 | vol.Required(ATTR_CONFIG_ENTRY_ID): str, 62 | vol.Required(ATTR_NODE): int, 63 | vol.Required(ATTR_ATTRIBUTE): int, 64 | vol.Required(ATTR_VALUE): vol.Any(int, float, str), 65 | } 66 | ) 67 | 68 | async def async_handle_set_value(call: ServiceCall) -> None: 69 | """Handle the set value service call.""" 70 | 71 | if not ( 72 | entry := hass.config_entries.async_get_entry( 73 | call.data[ATTR_CONFIG_ENTRY_ID] 74 | ) 75 | ): 76 | raise ServiceValidationError("Entry not found") 77 | if entry.state is not ConfigEntryState.LOADED: 78 | raise ServiceValidationError("Entry not loaded") 79 | homee: Homee = entry.runtime_data 80 | 81 | node = call.data.get(ATTR_NODE, 0) 82 | attribute = call.data.get(ATTR_ATTRIBUTE, 0) 83 | value = call.data.get(ATTR_VALUE, 0) 84 | 85 | await homee.set_value(node, attribute, value) 86 | 87 | hass.services.async_register( 88 | DOMAIN, SERVICE_SET_VALUE, async_handle_set_value, SET_VALUE_SCHEMA 89 | ) 90 | 91 | return True 92 | 93 | 94 | async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: 95 | """Set up homee from a config entry.""" 96 | # Create the Homee api object using host, user, 97 | # password & pyHomee instance from the config 98 | homee = Homee( 99 | host=entry.data[CONF_HOST], 100 | user=entry.data[CONF_USERNAME], 101 | password=entry.data[CONF_PASSWORD], 102 | device="pymee_" + hass.config.location_name, 103 | reconnect_interval=10, 104 | max_retries=100, 105 | ) 106 | 107 | # Start the homee websocket connection as a new task 108 | # and wait until we are connected 109 | try: 110 | await homee.get_access_token() 111 | except HomeeConnectionFailedException as exc: 112 | raise ConfigEntryNotReady( 113 | f"Connection to Homee failed: {exc.__cause__}" 114 | ) from exc 115 | except HomeeAuthFailedException as exc: 116 | raise ConfigEntryNotReady( 117 | f"Authentication to Homee failed: {exc.__cause__}" 118 | ) from exc 119 | 120 | hass.loop.create_task(homee.run()) 121 | await homee.wait_until_connected() 122 | 123 | # Migrate unique ids that are int. 124 | await _migrate_old_unique_ids(hass, entry.entry_id) 125 | 126 | # Log info about nodes, to facilitate recognition of unknown nodes. 127 | for node in homee.nodes: 128 | _LOGGER.info( 129 | "Found node %s, with following Data: %s", 130 | node.name, 131 | node.raw_data, 132 | ) 133 | 134 | entry.runtime_data = homee 135 | entry.async_on_unload(homee.disconnect) 136 | 137 | def _connection_update_callback(connected: bool) -> None: 138 | """Call when the device is notified of changes.""" 139 | if connected: 140 | _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) 141 | else: 142 | _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST]) 143 | 144 | homee.add_connection_listener(_connection_update_callback) 145 | 146 | # create device register entry 147 | device_registry = dr.async_get(hass) 148 | device_registry.async_get_or_create( 149 | config_entry_id=entry.entry_id, 150 | connections={ 151 | (dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address)) 152 | }, 153 | identifiers={(DOMAIN, homee.settings.uid)}, 154 | manufacturer="homee", 155 | name=homee.settings.homee_name, 156 | model="homee", 157 | sw_version=homee.settings.version, 158 | hw_version="TBD", 159 | ) 160 | 161 | # Forward entry setup to the platforms 162 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 163 | 164 | entry.async_on_unload(entry.add_update_listener(async_update_entry)) 165 | 166 | return True 167 | 168 | 169 | async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: 170 | """Unload a homee config entry.""" 171 | # Unload platforms 172 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 173 | 174 | if unload_ok: 175 | # Get Homee object and remove it from data 176 | homee: Homee = entry.runtime_data 177 | 178 | # Schedule homee disconnect 179 | homee.disconnect() 180 | 181 | return unload_ok 182 | 183 | 184 | async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 185 | """Reload homee integration after config change.""" 186 | hass.config_entries.async_schedule_reload(entry.entry_id) 187 | 188 | 189 | async def async_remove_config_entry_device( 190 | hass: HomeAssistant, config_entry: HomeeConfigEntry, device_entry: dr.DeviceEntry 191 | ) -> bool: 192 | """Remove a config entry from a device.""" 193 | homee = config_entry.runtime_data 194 | model = NodeProfile[device_entry.model.upper()].value 195 | for node in homee.nodes: 196 | # 'identifiers' is a set of tuples, so we need to check for the tuple. 197 | if ("homee", str(node.id)) in device_entry.identifiers: 198 | if node.profile == model: 199 | # If Node is still present in Homee, don't delete. 200 | return False 201 | 202 | return True 203 | 204 | 205 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 206 | """Migrate old entry.""" 207 | if config_entry.version == 2: 208 | _LOGGER.info("Migrating from version %s", config_entry.version) 209 | 210 | _LOGGER.info("Migrating device UIDs.") 211 | device_registry = dr.async_get(hass) 212 | device_entries = dr.async_entries_for_config_entry( 213 | device_registry, config_entry.entry_id 214 | ) 215 | for device in device_entries: 216 | node_id: str = "" 217 | for data_tuple in device.identifiers: 218 | if data_tuple[0] == DOMAIN: 219 | node_id = data_tuple[1] 220 | if node_id == "pymee_home": 221 | device_registry.async_update_device( 222 | device_id=device.id, 223 | new_identifiers={(DOMAIN, f"{config_entry.unique_id}")}, 224 | ) 225 | else: 226 | device_registry.async_update_device( 227 | device_id=device.id, 228 | new_identifiers={(DOMAIN, f"{config_entry.unique_id}-{node_id}")}, 229 | ) 230 | _LOGGER.info("Successfully migrated device UIDs") 231 | 232 | _LOGGER.info("Migrating Config Entry data") 233 | new_data = {**config_entry.data} 234 | hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) 235 | _LOGGER.info("Config Entry successfully migrated.") 236 | 237 | _LOGGER.info("Migration to v%s successful", config_entry.version) 238 | 239 | if config_entry.version == 3: 240 | _LOGGER.info("Migrating from version %s", config_entry.version) 241 | _LOGGER.info("Deleting options from config entry.") 242 | new_options = {} 243 | hass.config_entries.async_update_entry( 244 | config_entry, options=new_options, version=1 245 | ) 246 | _LOGGER.info("Migration to v%s successful", config_entry.version) 247 | 248 | return True 249 | 250 | 251 | async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None: 252 | entity_registry = er.async_get(hass) 253 | 254 | @callback 255 | def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None: 256 | # Climate entities had a string unique id. 257 | if isinstance(entity_entry.unique_id, int): 258 | new_unique_id = f"{entity_entry.unique_id}-climate" 259 | if existing_entity_id := entity_registry.async_get_entity_id( 260 | entity_entry.domain, entity_entry.platform, new_unique_id 261 | ): 262 | _LOGGER.error( 263 | "Cannot migrate to unique_id '%s', already exists for '%s', " 264 | "You may have to delete unavailable ring entities", 265 | new_unique_id, 266 | existing_entity_id, 267 | ) 268 | return None 269 | _LOGGER.info("Fixing non string unique id %s", entity_entry.unique_id) 270 | return {"new_unique_id": new_unique_id} 271 | 272 | return None 273 | 274 | await er.async_migrate_entries(hass, entry_id, _async_migrator) 275 | -------------------------------------------------------------------------------- /custom_components/homee/cover.py: -------------------------------------------------------------------------------- 1 | """The homee cover platform.""" 2 | 3 | from typing import Any, cast 4 | 5 | from pyHomee.const import AttributeType, NodeProfile 6 | from pyHomee.model import HomeeAttribute, HomeeNode 7 | 8 | from homeassistant.components.cover import ( 9 | ATTR_POSITION, 10 | ATTR_TILT_POSITION, 11 | CoverDeviceClass, 12 | CoverEntity, 13 | CoverEntityFeature, 14 | ) 15 | from homeassistant.const import Platform 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | 19 | from . import HomeeConfigEntry 20 | from .entity import HomeeNodeEntity 21 | from .helpers import migrate_old_unique_ids 22 | 23 | PARALLEL_UPDATES = 0 24 | 25 | OPEN_CLOSE_ATTRIBUTES = [ 26 | AttributeType.OPEN_CLOSE, 27 | AttributeType.SLAT_ROTATION_IMPULSE, 28 | AttributeType.UP_DOWN, 29 | ] 30 | POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] 31 | 32 | 33 | def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: 34 | """Return the attribute used for opening/closing the cover.""" 35 | # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. 36 | if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None: 37 | open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE) 38 | 39 | return open_close 40 | 41 | 42 | def get_cover_features( 43 | node: HomeeNode, open_close_attribute: HomeeAttribute | None 44 | ) -> CoverEntityFeature: 45 | """Determine the supported cover features of a homee node based on the available attributes.""" 46 | features = CoverEntityFeature(0) 47 | 48 | if (open_close_attribute is not None) and open_close_attribute.editable: 49 | features |= ( 50 | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP 51 | ) 52 | 53 | # Check for up/down position settable. 54 | attribute = node.get_attribute_by_type(AttributeType.POSITION) 55 | if attribute is not None: 56 | if attribute.editable: 57 | features |= CoverEntityFeature.SET_POSITION 58 | 59 | if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None: 60 | features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT 61 | 62 | if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None: 63 | features |= CoverEntityFeature.SET_TILT_POSITION 64 | 65 | return features 66 | 67 | 68 | def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: 69 | """Determine the device class a homee node based on the node profile.""" 70 | COVER_DEVICE_PROFILES = { 71 | NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, 72 | NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE, 73 | NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, 74 | } 75 | 76 | return COVER_DEVICE_PROFILES.get(node.profile) 77 | 78 | 79 | async def async_setup_entry( 80 | hass: HomeAssistant, 81 | config_entry: HomeeConfigEntry, 82 | async_add_devices: AddEntitiesCallback, 83 | ) -> None: 84 | """Add the homee platform for the cover integration.""" 85 | 86 | devices: list[HomeeCover] = [] 87 | devices.extend( 88 | HomeeCover(node, config_entry) 89 | for node in config_entry.runtime_data.nodes 90 | if is_cover_node(node) 91 | ) 92 | 93 | if devices: 94 | await migrate_old_unique_ids(hass, devices, Platform.COVER) 95 | async_add_devices(devices) 96 | 97 | 98 | def is_cover_node(node: HomeeNode) -> bool: 99 | """Determine if a node is controllable as a homee cover based on its profile and attributes.""" 100 | return node.profile in [ 101 | NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, 102 | NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, 103 | NodeProfile.ENTRANCE_GATE_OPERATOR, 104 | NodeProfile.GARAGE_DOOR_OPERATOR, 105 | NodeProfile.SHUTTER_POSITION_SWITCH, 106 | ] 107 | 108 | 109 | class HomeeCover(HomeeNodeEntity, CoverEntity): 110 | """Representation of a homee cover device.""" 111 | 112 | _attr_name = None 113 | 114 | def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: 115 | """Initialize a homee cover entity.""" 116 | super().__init__(node, entry) 117 | self._open_close_attribute = get_open_close_attribute(node) 118 | self._attr_supported_features = get_cover_features( 119 | node, self._open_close_attribute 120 | ) 121 | self._attr_device_class = get_device_class(node) 122 | self._attr_unique_id = ( 123 | f"{self._attr_unique_id}-{self._open_close_attribute.id}" 124 | if self._open_close_attribute is not None 125 | else f"{self._attr_unique_id}-0" 126 | ) 127 | 128 | @property 129 | def old_unique_id(self) -> str: 130 | """Return the old not so unique id of the cover entity.""" 131 | return f"{self._node.id}-cover" 132 | 133 | @property 134 | def current_cover_position(self) -> int | None: 135 | """Return the cover's position.""" 136 | # Translate the homee position values to HA's 0-100 scale 137 | if ( 138 | attribute := self._node.get_attribute_by_type(AttributeType.POSITION) 139 | ) is not None: 140 | homee_min = attribute.minimum 141 | homee_max = attribute.maximum 142 | homee_position = attribute.current_value 143 | position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 144 | 145 | return int(100 - position) 146 | 147 | return None 148 | 149 | @property 150 | def current_cover_tilt_position(self) -> int | None: 151 | """Return the cover's tilt position.""" 152 | # Translate the homee position values to HA's 0-100 scale 153 | if ( 154 | attribute := self._node.get_attribute_by_type( 155 | AttributeType.SHUTTER_SLAT_POSITION 156 | ) 157 | ) is not None: 158 | homee_min = attribute.minimum 159 | homee_max = attribute.maximum 160 | homee_position = attribute.current_value 161 | position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 162 | 163 | return int(100 - position) 164 | 165 | return None 166 | 167 | @property 168 | def is_opening(self) -> bool | None: 169 | """Return the opening status of the cover.""" 170 | if self._open_close_attribute is not None: 171 | return ( 172 | self._open_close_attribute.get_value() == 3 173 | if not self._open_close_attribute.is_reversed 174 | else self._open_close_attribute.get_value() == 4 175 | ) 176 | 177 | return None 178 | 179 | @property 180 | def is_closing(self) -> bool | None: 181 | """Return the closing status of the cover.""" 182 | if self._open_close_attribute is not None: 183 | return ( 184 | self._open_close_attribute.get_value() == 4 185 | if not self._open_close_attribute.is_reversed 186 | else self._open_close_attribute.get_value() == 3 187 | ) 188 | 189 | return None 190 | 191 | @property 192 | def is_closed(self) -> bool | None: 193 | """Return if the cover is closed.""" 194 | if ( 195 | attribute := self._node.get_attribute_by_type(AttributeType.POSITION) 196 | ) is not None: 197 | return attribute.get_value() == attribute.maximum 198 | 199 | if self._open_close_attribute is not None: 200 | if not self._open_close_attribute.is_reversed: 201 | return self._open_close_attribute.get_value() == 1 202 | 203 | return self._open_close_attribute.get_value() == 0 204 | 205 | # If none of the above is present, it might be a slat only cover. 206 | if ( 207 | attribute := self._node.get_attribute_by_type( 208 | AttributeType.SHUTTER_SLAT_POSITION 209 | ) 210 | ) is not None: 211 | return attribute.get_value() == attribute.minimum 212 | 213 | return None 214 | 215 | async def async_open_cover(self, **kwargs: Any) -> None: 216 | """Open the cover.""" 217 | assert self._open_close_attribute is not None 218 | if not self._open_close_attribute.is_reversed: 219 | await self.async_set_value(self._open_close_attribute, 0) 220 | else: 221 | await self.async_set_value(self._open_close_attribute, 1) 222 | 223 | async def async_close_cover(self, **kwargs: Any) -> None: 224 | """Close cover.""" 225 | assert self._open_close_attribute is not None 226 | if not self._open_close_attribute.is_reversed: 227 | await self.async_set_value(self._open_close_attribute, 1) 228 | else: 229 | await self.async_set_value(self._open_close_attribute, 0) 230 | 231 | async def async_set_cover_position(self, **kwargs: Any) -> None: 232 | """Move the cover to a specific position.""" 233 | if CoverEntityFeature.SET_POSITION in self.supported_features: 234 | position = 100 - cast(int, kwargs[ATTR_POSITION]) 235 | 236 | # Convert position to range of our entity. 237 | if ( 238 | attribute := self._node.get_attribute_by_type(AttributeType.POSITION) 239 | ) is not None: 240 | homee_min = attribute.minimum 241 | homee_max = attribute.maximum 242 | homee_position = (position / 100) * (homee_max - homee_min) + homee_min 243 | 244 | await self.async_set_value(attribute, homee_position) 245 | 246 | async def async_stop_cover(self, **kwargs: Any) -> None: 247 | """Stop the cover.""" 248 | if self._open_close_attribute is not None: 249 | await self.async_set_value(self._open_close_attribute, 2) 250 | 251 | async def async_open_cover_tilt(self, **kwargs: Any) -> None: 252 | """Open the cover tilt.""" 253 | if ( 254 | slat_attribute := self._node.get_attribute_by_type( 255 | AttributeType.SLAT_ROTATION_IMPULSE 256 | ) 257 | ) is not None: 258 | if not slat_attribute.is_reversed: 259 | await self.async_set_value(slat_attribute, 2) 260 | else: 261 | await self.async_set_value(slat_attribute, 1) 262 | 263 | async def async_close_cover_tilt(self, **kwargs: Any) -> None: 264 | """Close the cover tilt.""" 265 | if ( 266 | slat_attribute := self._node.get_attribute_by_type( 267 | AttributeType.SLAT_ROTATION_IMPULSE 268 | ) 269 | ) is not None: 270 | if not slat_attribute.is_reversed: 271 | await self.async_set_value(slat_attribute, 1) 272 | else: 273 | await self.async_set_value(slat_attribute, 2) 274 | 275 | async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: 276 | """Move the cover tilt to a specific position.""" 277 | if CoverEntityFeature.SET_TILT_POSITION in self.supported_features: 278 | position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) 279 | 280 | # Convert position to range of our entity. 281 | if ( 282 | attribute := self._node.get_attribute_by_type( 283 | AttributeType.SHUTTER_SLAT_POSITION 284 | ) 285 | ) is not None: 286 | homee_min = attribute.minimum 287 | homee_max = attribute.maximum 288 | homee_position = (position / 100) * (homee_max - homee_min) + homee_min 289 | 290 | await self.async_set_value(attribute, homee_position) 291 | -------------------------------------------------------------------------------- /custom_components/homee/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "homee {name} ({host})", 4 | "abort": { 5 | "already_configured": "Device is already configured" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Invalid authentication", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Configure homee", 15 | "description": "Manually enter the ip address of the homee you want to connect.", 16 | "data": { 17 | "host": "Host", 18 | "password": "Password", 19 | "username": "Username", 20 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity" 21 | } 22 | }, 23 | "zeroconf_confirm": { 24 | "title": "Configure homee", 25 | "description": "Discovered homee {id} at {host}", 26 | "data": { 27 | "host": "Host", 28 | "password": "Password", 29 | "username": "Username", 30 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity." 31 | } 32 | }, 33 | "groups": { 34 | "title": "Group Configuration", 35 | "description": "Configure the groups. You can still change window & door groups later.", 36 | "data": { 37 | "import_groups": "Import devices in the following groups:", 38 | "window_groups": "Groups that contain window sensors:", 39 | "door_groups": "Groups that contain door sensors:" 40 | } 41 | }, 42 | "reconfigure": { 43 | "title": "Reconfigure homee", 44 | "description": "Change settings of homee.", 45 | "data": { 46 | "host": "Host", 47 | "password": "Password", 48 | "username": "Username", 49 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity" 50 | } 51 | } 52 | } 53 | }, 54 | "options": { 55 | "step": { 56 | "init": { 57 | "description": "Configure the homee integration. You may need to restart Home Assistant to apply the changes.", 58 | "data": { 59 | "window_groups": "Groups that contain window sensors:", 60 | "door_groups": "Groups that contain door sensors:", 61 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity." 62 | } 63 | } 64 | } 65 | }, 66 | "services": { 67 | "set_value": { 68 | "name": "Set Value", 69 | "description": "Set an attribute value of a homee node.", 70 | "fields": { 71 | "config_entry_id": { 72 | "name": "Target Homee", 73 | "description": "Homee on which the action will be executed." 74 | }, 75 | "node": { 76 | "name": "Node", 77 | "description": "The node ID." 78 | }, 79 | "attribute": { 80 | "name": "Attribute", 81 | "description": "The attribute ID." 82 | }, 83 | "value": { 84 | "name": "Value", 85 | "description": "The value to set." 86 | } 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "alarm_control_panel": { 92 | "homee_status": { 93 | "name": "Status", 94 | "state": { 95 | "armed_home": "Home", 96 | "armed_night": "Sleeping", 97 | "armed_away": "Away", 98 | "armed_vacation": "Vacation" 99 | } 100 | } 101 | }, 102 | "binary_sensor": { 103 | "battery_low": { 104 | "name": "Battery Low" 105 | }, 106 | "blackout_alarm": { 107 | "name": "Blackout" 108 | }, 109 | "carbon_dioxide": { 110 | "name": "Carbon dioxide alarm" 111 | }, 112 | "door": { 113 | "name": "Door" 114 | }, 115 | "flood": { 116 | "name": "Flood" 117 | }, 118 | "heat": { 119 | "name": "Temperature" 120 | }, 121 | "leak_alarm": { 122 | "name": "Leak" 123 | }, 124 | "load_alarm": { 125 | "name": "Load" 126 | }, 127 | "lock": { 128 | "name": "Lock" 129 | }, 130 | "low_temperature_alarm": { 131 | "name": "Low temperature" 132 | }, 133 | "malfunction": { 134 | "name": "Malfunction" 135 | }, 136 | "maximum": { 137 | "name": "Maximum alarm" 138 | }, 139 | "minimum_alarm": { 140 | "name": "Minimum alarm" 141 | }, 142 | "motion": { 143 | "name": "Motion" 144 | }, 145 | "motor_blocked_alarm": { 146 | "name": "Motor blocked" 147 | }, 148 | "opening": { 149 | "name": "Opening" 150 | }, 151 | "overcurrent": { 152 | "name": "Overcurrent" 153 | }, 154 | "overload": { 155 | "name": "Overload" 156 | }, 157 | "power_supply_alarm": { 158 | "name": "Power supply" 159 | }, 160 | "plug": { 161 | "name": "Plug" 162 | }, 163 | "rain": { 164 | "name": "Rain" 165 | }, 166 | "replace_filter": { 167 | "name": "Replace filter" 168 | }, 169 | "smoke": { 170 | "name": "Smoke" 171 | }, 172 | "storage_alarm": { 173 | "name": "Storage" 174 | }, 175 | "surge": { 176 | "name": "Surge" 177 | }, 178 | "tamper": { 179 | "name": "Tamper" 180 | }, 181 | "voltage_drop": { 182 | "name": "Voltage Drop" 183 | }, 184 | "window": { 185 | "name": "Window" 186 | }, 187 | "water_alarm": { 188 | "name": "Water" 189 | } 190 | }, 191 | "climate": { 192 | "homee": { 193 | "state_attributes": { 194 | "preset_mode": { 195 | "state": { 196 | "manual": "Manual" 197 | } 198 | } 199 | } 200 | } 201 | }, 202 | "event": { 203 | "up_down_remote": { 204 | "name": "Up Down Remote", 205 | "state_attributes": { 206 | "event_type": { 207 | "state": { 208 | "0": "None", 209 | "1": "Up", 210 | "2": "Down", 211 | "3": "Stop", 212 | "4": "Up (long press)", 213 | "5": "Down (long press)", 214 | "6": "Stop (long press)", 215 | "7": "C-Button", 216 | "9": "A-Button" 217 | } 218 | } 219 | } 220 | } 221 | }, 222 | "fan": { 223 | "homee": { 224 | "state_attributes": { 225 | "preset_mode": { 226 | "state": { 227 | "manual": "Manual", 228 | "auto": "Automatic", 229 | "summer": "Summer" 230 | } 231 | } 232 | } 233 | } 234 | }, 235 | "light": { 236 | "light_instance": { 237 | "name": "Light {instance}" 238 | } 239 | }, 240 | "number": { 241 | "number_button_brightness_active": { 242 | "name": "Button brightness (active)" 243 | }, 244 | "number_button_brightness_dimmed": { 245 | "name": "Button brightness (dimmed)" 246 | }, 247 | "number_display_brightness_active": { 248 | "name": "Display brightness (active)" 249 | }, 250 | "number_display_brightness_dimmed": { 251 | "name": "Display brightness (dimmed)" 252 | }, 253 | "number_down_position": { 254 | "name": "Down Position" 255 | }, 256 | "number_down_slat_position": { 257 | "name": "Down Slat Position" 258 | }, 259 | "number_down_time": { 260 | "name": "Down-movement duration" 261 | }, 262 | "number_endposition_configuration": { 263 | "name": "End position" 264 | }, 265 | "number_external_temperature_offset": { 266 | "name": "External temperature offset" 267 | }, 268 | "number_floor_temperature_offset": { 269 | "name": "Floor temperature offset" 270 | }, 271 | "number_motion_alarm_cancelation_delay": { 272 | "name": "Motion Alarm Delay" 273 | }, 274 | "number_open_window_detection_sensibility": { 275 | "name": "Window Open Sensibility" 276 | }, 277 | "number_polling_interval": { 278 | "name": "Polling Interval" 279 | }, 280 | "number_shutter_slat_time": { 281 | "name": "Slat Turn Duration" 282 | }, 283 | "number_slat_max_angle": { 284 | "name": "Maximum Slat Angle" 285 | }, 286 | "number_slat_min_angle": { 287 | "name": "Minimum Slat Angle" 288 | }, 289 | "number_slat_steps": { 290 | "name": "Slat Steps" 291 | }, 292 | "number_target_temperature": { 293 | "name": "Target Temperature" 294 | }, 295 | "number_temperature_offset": { 296 | "name": "Temperature Offset" 297 | }, 298 | "number_temperature_report_interval": { 299 | "name": "Temperature report interval" 300 | }, 301 | "number_up_time": { 302 | "name": "Up-movement duration" 303 | }, 304 | "number_valve_position": { 305 | "name": "Valve Position" 306 | }, 307 | "number_wake_up_interval": { 308 | "name": "Wake-Up Interval" 309 | }, 310 | "number_wind_monitoring_state": { 311 | "name": "Wind Monitoring State" 312 | } 313 | }, 314 | "select": { 315 | "display_temperature_selection": { 316 | "name": "Displayed temperature", 317 | "state": { 318 | "selected": "target", 319 | "current": "measured" 320 | } 321 | }, 322 | "repeater_mode": { 323 | "name": "EnOcean repeater mode", 324 | "state": { 325 | "off": "Off", 326 | "level1": "Level 1", 327 | "level2": "Level 2" 328 | } 329 | } 330 | }, 331 | "sensor": { 332 | "brightness_instance": { 333 | "name": "Illuminance {instance}" 334 | }, 335 | "button_state": { 336 | "name": "Button state" 337 | }, 338 | "button_state_instance": { 339 | "name": "Button state {instance}" 340 | }, 341 | "current_instance": { 342 | "name": "Current {instance}" 343 | }, 344 | "dawn": { 345 | "name": "Dawn" 346 | }, 347 | "device_temperature": { 348 | "name": "Device temperature" 349 | }, 350 | "energy_instance": { 351 | "name": "Energy {instance}" 352 | }, 353 | "exhaust_motor_revs": { 354 | "name": "Exhaust motor speed" 355 | }, 356 | "external_temperature": { 357 | "name": "External temperature" 358 | }, 359 | "floor_temperature": { 360 | "name": "Floor temperature" 361 | }, 362 | "indoor_humidity": { 363 | "name": "Indoor humidity" 364 | }, 365 | "indoor_humidity_instance": { 366 | "name": "Indoor humidity {instance}" 367 | }, 368 | "indoor_temperature": { 369 | "name": "Indoor temperature" 370 | }, 371 | "indoor_temperature_instance": { 372 | "name": "Indoor temperature {instance}" 373 | }, 374 | "intake_motor_revs": { 375 | "name": "Intake motor speed" 376 | }, 377 | "level": { 378 | "name": "Level" 379 | }, 380 | "link_quality": { 381 | "name": "Link quality" 382 | }, 383 | "node_state": { 384 | "name": "Node state" 385 | }, 386 | "operating_hours": { 387 | "name": "Operating hours" 388 | }, 389 | "outdoor_humidity": { 390 | "name": "Outdoor humidity" 391 | }, 392 | "outdoor_humidity_instance": { 393 | "name": "Outdoor humidity {instance}" 394 | }, 395 | "outdoor_temperature": { 396 | "name": "Outdoor temperature" 397 | }, 398 | "outdoor_temperature_instance": { 399 | "name": "Outdoor temperature {instance}" 400 | }, 401 | "position": { 402 | "name": "Position" 403 | }, 404 | "power_instance": { 405 | "name": "Power {instance}" 406 | }, 407 | "rainfall_day": { 408 | "name": "Rainfall today" 409 | }, 410 | "rainfall_hour": { 411 | "name": "Rainfall last hour" 412 | }, 413 | "total_current": { 414 | "name": "Total current" 415 | }, 416 | "total_energy": { 417 | "name": "Total energy" 418 | }, 419 | "total_power": { 420 | "name": "Total power" 421 | }, 422 | "total_voltage": { 423 | "name": "Total voltage" 424 | }, 425 | "up_down": { 426 | "name": "State", 427 | "state": { 428 | "closed": "Closed", 429 | "closing": "Closing", 430 | "open": "Open", 431 | "opening": "Opening", 432 | "partial": "Partially open" 433 | } 434 | }, 435 | "uv": { 436 | "name": "Ultraviolet" 437 | }, 438 | "valve_position": { 439 | "name": "Valve position" 440 | }, 441 | "voltage_instance": { 442 | "name": "Voltage {instance}" 443 | }, 444 | "wake_up_interval": { 445 | "name": "Wake-Up Interval" 446 | }, 447 | "window_position": { 448 | "name": "Window position", 449 | "state": { 450 | "closed": "Closed", 451 | "open": "Open", 452 | "tilted": "Tilted" 453 | } 454 | } 455 | }, 456 | "switch": { 457 | "automatic_mode_impulse": { 458 | "name": "Automatic mode impulse" 459 | }, 460 | "briefly_open_impulse": { 461 | "name": "Briefly open impulse" 462 | }, 463 | "external_binary_input": { 464 | "name": "Child lock" 465 | }, 466 | "identification_mode": { 467 | "name": "Identification mode" 468 | }, 469 | "impulse": { 470 | "name": "Impulse" 471 | }, 472 | "impulse_1": { 473 | "name": "Impulse 1" 474 | }, 475 | "impulse_2": { 476 | "name": "Impulse 2" 477 | }, 478 | "impulse_3": { 479 | "name": "Impulse 3" 480 | }, 481 | "impulse_4": { 482 | "name": "Impulse 4" 483 | }, 484 | "light_impulse": { 485 | "name": "Light impulse" 486 | }, 487 | "light_impulse_1": { 488 | "name": "Light impulse 1" 489 | }, 490 | "light_impulse_2": { 491 | "name": "Light impulse 2" 492 | }, 493 | "light_impulse_3": { 494 | "name": "Light impulse 3" 495 | }, 496 | "light_impulse_4": { 497 | "name": "Light impulse 4" 498 | }, 499 | "manual_operation": { 500 | "name": "Manual operation" 501 | }, 502 | "motor_rotation": { 503 | "name": "Motor rotation direction" 504 | }, 505 | "open_partial_impulse": { 506 | "name": "Open partial impulse" 507 | }, 508 | "permanently_open_impulse": { 509 | "name": "Permanently open impulse" 510 | }, 511 | "reset_meter": { 512 | "name": "Reset meter" 513 | }, 514 | "reset_meter_1": { 515 | "name": "Reset meter 1" 516 | }, 517 | "reset_meter_2": { 518 | "name": "Reset meter 2" 519 | }, 520 | "reset_meter_3": { 521 | "name": "Reset meter 3" 522 | }, 523 | "reset_meter_4": { 524 | "name": "Reset meter 4" 525 | }, 526 | "restore_last_known_state": { 527 | "name": "Restore last known state" 528 | }, 529 | "switch_type": { 530 | "name": "Switch type" 531 | }, 532 | "ventilate_impulse": { 533 | "name": "Ventilate impulse" 534 | }, 535 | "watchdog_on_off": { 536 | "name": "Watchdog" 537 | } 538 | } 539 | }, 540 | "exceptions": { 541 | "no_integer": { 542 | "message": "{service_attr} must be an integer." 543 | }, 544 | "no_float": { 545 | "message": "{service_attr} must be a number." 546 | }, 547 | "not_editable": { 548 | "message": "{entity} is currently not changeable." 549 | } 550 | } 551 | } -------------------------------------------------------------------------------- /custom_components/homee/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "homee {name} ({host})", 4 | "abort": { 5 | "already_configured": "Gerät ist bereits konfiguriert" 6 | }, 7 | "error": { 8 | "cannot_connect": "Verbindung fehlgeschlagen", 9 | "invalid_auth": "Ungültige Anmeldedaten", 10 | "unknown": "Unerwarteter Fehler" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "homee konfigurieren", 15 | "description": "Geben Sie manuell die IP-Adresse des homee ein, mit dem Sie eine Verbindung herstellen möchten.", 16 | "data": { 17 | "host": "Host", 18 | "password": "Kennwort", 19 | "username": "Benutzer", 20 | "add_homee_data": "Debug-Informationen für homee Geräte und Attribute aktivieren." 21 | } 22 | }, 23 | "zeroconf_confirm": { 24 | "title": "homee konfigurieren", 25 | "description": "Entdeckt homee {id} unter {host}", 26 | "data": { 27 | "host": "Host", 28 | "password": "Kennwort", 29 | "username": "Benutzer", 30 | "add_homee_data": "Debug-Informationen für homee Geräte und Attribute aktivieren." 31 | } 32 | }, 33 | "groups": { 34 | "title": "Gruppen-Konfiguration", 35 | "description": "Konfigurieren Sie die Gruppen. Sie können Fenster- und Türgruppen auch später noch ändern.", 36 | "data": { 37 | "window_groups": "Gruppen, die Fenstersensoren enthalten:", 38 | "door_groups": "Gruppen, die Türsensoren enthalten:" 39 | } 40 | }, 41 | "reconfigure": { 42 | "title": "homee rekonfigurieren", 43 | "description": "Ändern sie die Einstellungen für ihren homee.", 44 | "data": { 45 | "host": "Host", 46 | "password": "Kennwort", 47 | "username": "Benutzer", 48 | "add_homee_data": "Debug-Informationen für homee Geräte und Attribute aktivieren." 49 | } 50 | } 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "description": "Konfigurieren Sie die Homee-Integration. Möglicherweise müssen Sie Home Assistant neu starten, um die Änderungen zu übernehmen.", 57 | "data": { 58 | "window_groups": "Gruppen, die Fenstersensoren enthalten:", 59 | "door_groups": "Gruppen, die Türsensoren enthalten:", 60 | "add_homee_data": "Debug-Informationen für homee Geräte und Attribute aktivieren." 61 | } 62 | } 63 | } 64 | }, 65 | "services": { 66 | "set_value": { 67 | "name": "Wert einstellen", 68 | "description": "Attributwert eines homee-Knotens setzen.", 69 | "fields": { 70 | "config_entry_id": { 71 | "name": "Ziel-Homee", 72 | "description": "Homee auf dem die Aktion ausgeführt wird." 73 | }, 74 | "node": { 75 | "name": "Node", 76 | "description": "Node ID." 77 | }, 78 | "attribute": { 79 | "name": "Attribut", 80 | "description": "Attribut ID." 81 | }, 82 | "value": { 83 | "name": "Wert", 84 | "description": "Der einzustellende Wert." 85 | } 86 | } 87 | } 88 | }, 89 | "entity": { 90 | "alarm_control_panel": { 91 | "homee_status": { 92 | "name": "Status", 93 | "state": { 94 | "armed_home": "Zuhause", 95 | "armed_night": "Nacht", 96 | "armed_away": "Abwesend", 97 | "armed_vacation": "Urlaub" 98 | } 99 | } 100 | }, 101 | "binary_sensor": { 102 | "battery_low": { 103 | "name": "Batterie schwach" 104 | }, 105 | "carbon_dioxide": { 106 | "name": "Kohlendioxidalarm" 107 | }, 108 | "door": { 109 | "name": "Tür" 110 | }, 111 | "flood": { 112 | "name": "Überflutung" 113 | }, 114 | "heat": { 115 | "name": "Temperatur" 116 | }, 117 | "leak_alarm": { 118 | "name": "Leckage" 119 | }, 120 | "load_alarm": { 121 | "name": "Last" 122 | }, 123 | "lock": { 124 | "name": "Schloss" 125 | }, 126 | "low_temperature_alarm": { 127 | "name": "Kälte" 128 | }, 129 | "malfunction": { 130 | "name": "Fehler" 131 | }, 132 | "maximum": { 133 | "name": "Maximumalarm" 134 | }, 135 | "minimum_alarm": { 136 | "name": "Minimumalarm" 137 | }, 138 | "motion": { 139 | "name": "Bewegung" 140 | }, 141 | "motor_blocked_alarm": { 142 | "name": "Motor blockiert" 143 | }, 144 | "opening": { 145 | "name": "Opening" 146 | }, 147 | "overcurrent": { 148 | "name": "Überstrom" 149 | }, 150 | "overload": { 151 | "name": "Überlastung" 152 | }, 153 | "plug": { 154 | "name": "Steckdose" 155 | }, 156 | "rain": { 157 | "name": "Regen" 158 | }, 159 | "replace_filter": { 160 | "name": "Filter tauschen" 161 | }, 162 | "smoke": { 163 | "name": "Rauch" 164 | }, 165 | "storage_alarm": { 166 | "name": "Speicher" 167 | }, 168 | "surge": { 169 | "name": "Kurzschluss" 170 | }, 171 | "tamper": { 172 | "name": "Manipulation" 173 | }, 174 | "voltage_drop": { 175 | "name": "Spannungsabfall" 176 | }, 177 | "window": { 178 | "name": "Fenster" 179 | }, 180 | "water_alarm": { 181 | "name": "Wasser" 182 | } 183 | }, 184 | "climate": { 185 | "homee": { 186 | "state_attributes": { 187 | "preset_mode": { 188 | "state": { 189 | "manual": "Manuell" 190 | } 191 | } 192 | } 193 | } 194 | }, 195 | "event": { 196 | "up_down_remote": { 197 | "name": "Up Down Remote", 198 | "state_attributes": { 199 | "event_type": { 200 | "state": { 201 | "0": "Keines", 202 | "1": "Hoch", 203 | "2": "Runter", 204 | "3": "Stop", 205 | "4": "Hoch (halten)", 206 | "5": "Runter (halten)", 207 | "6": "Stop (halten)", 208 | "7": "C-Taste", 209 | "9": "A-Taste" 210 | } 211 | } 212 | } 213 | } 214 | }, 215 | "fan": { 216 | "homee": { 217 | "state_attributes": { 218 | "preset_mode": { 219 | "state": { 220 | "manual": "Manuell", 221 | "auto": "Automatik", 222 | "summer": "Sommer" 223 | } 224 | } 225 | } 226 | } 227 | }, 228 | "light": { 229 | "light_instance": { 230 | "name": "Licht {instance}" 231 | } 232 | }, 233 | "number": { 234 | "number_button_brightness_active": { 235 | "name": "Tastenhelligkeit (aktiv)" 236 | }, 237 | "number_button_brightness_dimmed": { 238 | "name": "Tastenhelligkeit (gedimmt)" 239 | }, 240 | "number_display_brightness_active": { 241 | "name": "Bildschirmhelligkeit (aktiv)" 242 | }, 243 | "number_display_brightness_dimmed": { 244 | "name": "Bildschirmhelligkeit (gedimmt)" 245 | }, 246 | "number_down_position": { 247 | "name": "Untere Position" 248 | }, 249 | "number_down_slat_position": { 250 | "name": "Down Slat Position" 251 | }, 252 | "number_down_time": { 253 | "name": "Dauer Runterfahren" 254 | }, 255 | "number_endposition_configuration": { 256 | "name": "Endposition" 257 | }, 258 | "number_external_temperature_offset": { 259 | "name": "Externe Temperatur Versatz" 260 | }, 261 | "number_floor_temperature_offset": { 262 | "name": "Bodentemperatur Versatz" 263 | }, 264 | "number_motion_alarm_cancelation_delay": { 265 | "name": "Bewegungsmelder Verzögerung" 266 | }, 267 | "number_open_window_detection_sensibility": { 268 | "name": "Fenster Offen Empfindlichkeit" 269 | }, 270 | "number_polling_interval": { 271 | "name": "Polling Intervall" 272 | }, 273 | "number_shutter_slat_time": { 274 | "name": "Lamellendrehzeit" 275 | }, 276 | "number_slat_max_angle": { 277 | "name": "Maximaler Lamellenwinkel" 278 | }, 279 | "number_slat_min_angle": { 280 | "name": "Minimaler Lamellenwinkel" 281 | }, 282 | "number_slat_steps": { 283 | "name": "Lamellenstufen" 284 | }, 285 | "number_target_temperature": { 286 | "name": "Zieltemperatur" 287 | }, 288 | "number_temperature_offset": { 289 | "name": "Temperatur Offset" 290 | }, 291 | "number_temperature_report_interval": { 292 | "name": "Temperaturmeldeintervall" 293 | }, 294 | "number_up_time": { 295 | "name": "Dauer Hochfahren" 296 | }, 297 | "number_valve_position": { 298 | "name": "Ventilöffnung" 299 | }, 300 | "number_wake_up_interval": { 301 | "name": "Aufwachintervall" 302 | }, 303 | "number_wind_monitoring_state": { 304 | "name": "Windüberwachung" 305 | } 306 | }, 307 | "select": { 308 | "display_temperature_selection": { 309 | "name": "Angezeigte Temperatur", 310 | "state": { 311 | "selected": "Solltemperatur", 312 | "current": "Isttemperatur" 313 | } 314 | }, 315 | "repeater_mode": { 316 | "name": "EnOcean Repeater Modus", 317 | "state": { 318 | "off": "Aus", 319 | "level1": "Level 1", 320 | "level2": "Level 2" 321 | } 322 | } 323 | }, 324 | "sensor": { 325 | "brightness_instance": { 326 | "name": "Beleuchtungsstärke {instance}" 327 | }, 328 | "button_state": { 329 | "name": "Schalterstellung" 330 | }, 331 | "button_state_instance": { 332 | "name": "Schalterstellung {instance}" 333 | }, 334 | "current_instance": { 335 | "name": "Stromstärke {instance}" 336 | }, 337 | "dawn": { 338 | "name": "Dämmerung" 339 | }, 340 | "device_temperature": { 341 | "name": "Gerätetemperatur" 342 | }, 343 | "energy_instance": { 344 | "name": "Energie {instance}" 345 | }, 346 | "exhaust_motor_revs": { 347 | "name": "Abluftmotordrehzahl" 348 | }, 349 | "external_temperature": { 350 | "name": "Externe Temperatur" 351 | }, 352 | "floor_temperature": { 353 | "name": "Bodentemperatur" 354 | }, 355 | "indoor_humidity": { 356 | "name": "Innenluftfeuchtigkeit" 357 | }, 358 | "indoor_humidity_instance": { 359 | "name": "Innenluftfeuchtigkeit {instance}" 360 | }, 361 | "indoor_temperature": { 362 | "name": "Innentemperatur" 363 | }, 364 | "indoor_temperature_instance": { 365 | "name": "Innentemperatur {instance}" 366 | }, 367 | "intake_motor_revs": { 368 | "name": "Zuluftmotordrehzahl" 369 | }, 370 | "level": { 371 | "name": "Füllstand" 372 | }, 373 | "link_quality": { 374 | "name": "Signalstärke" 375 | }, 376 | "node_sstate": { 377 | "name": "Node Status" 378 | }, 379 | "operating_hours": { 380 | "name": "Betriebsstunden" 381 | }, 382 | "outdoor_humidity": { 383 | "name": "Außenluftfeuchtigkeit" 384 | }, 385 | "outdoor_humidity_instance": { 386 | "name": "Außenluftfeuchtigkeit {instance}" 387 | }, 388 | "outdoor_temperature": { 389 | "name": "Außentemperatur" 390 | }, 391 | "outdoor_temperature_instance": { 392 | "name": "Außentemperatur {instance}" 393 | }, 394 | "position": { 395 | "name": "Position" 396 | }, 397 | "power_instance": { 398 | "name": "Leistung {instance}" 399 | }, 400 | "rainfall_hour": { 401 | "name": "Niederschlag letzte Stunde" 402 | }, 403 | "rainfall_day": { 404 | "name": "Niederschlag heute" 405 | }, 406 | "total_current": { 407 | "name": "Gesamtstromstärke" 408 | }, 409 | "total_energy": { 410 | "name": "Gesamtenergie" 411 | }, 412 | "total_power": { 413 | "name": "Gesamtleistung" 414 | }, 415 | "total_voltage": { 416 | "name": "Gesamtspannung" 417 | }, 418 | "up_down": { 419 | "name": "Status", 420 | "state": { 421 | "open": "Offen", 422 | "closed": "Geschlossen", 423 | "partial": "Teilweise offen", 424 | "opening": "Öffnet", 425 | "closing": "Schließt" 426 | } 427 | }, 428 | "uv": { 429 | "name": "UV Strahlung" 430 | }, 431 | "valve_position": { 432 | "name": "Ventilöffnung" 433 | }, 434 | "voltage_instance": { 435 | "name": "Spannung {instance}" 436 | }, 437 | "wake_up_interval": { 438 | "name": "Aufwachintervall" 439 | }, 440 | "window_position": { 441 | "name": "Fensterstellung", 442 | "state": { 443 | "0": "Geschlossen", 444 | "1": "Offen", 445 | "2": "Gekippt" 446 | } 447 | } 448 | }, 449 | "switch": { 450 | "automatic_mode_impulse": { 451 | "name": "Automatikmodus Impuls" 452 | }, 453 | "briefly_open_impulse": { 454 | "name": "Kurzzeitig öffnen Impuls" 455 | }, 456 | "external_binary_input": { 457 | "name": "Kindersicherung" 458 | }, 459 | "identification_mode": { 460 | "name": "Erkennungsmodus" 461 | }, 462 | "impulse": { 463 | "name": "Impuls" 464 | }, 465 | "impulse_1": { 466 | "name": "Impuls 1" 467 | }, 468 | "impulse_2": { 469 | "name": "Impuls 2" 470 | }, 471 | "impulse_3": { 472 | "name": "Impuls 3" 473 | }, 474 | "impulse_4": { 475 | "name": "Impuls 4" 476 | }, 477 | "light_impulse": { 478 | "name": "Licht Impuls" 479 | }, 480 | "light_impulse_1": { 481 | "name": "Licht Impuls 1" 482 | }, 483 | "light_impulse_2": { 484 | "name": "Licht Impuls 2" 485 | }, 486 | "light_impulse_3": { 487 | "name": "Licht Impuls 3" 488 | }, 489 | "light_impulse_4": { 490 | "name": "Licht Impuls 4" 491 | }, 492 | "manual_operation": { 493 | "name": "Handbetrieb" 494 | }, 495 | "motor_rotation": { 496 | "name": "Motor Drehrichtung" 497 | }, 498 | "open_partial_impulse": { 499 | "name": "Teilweise öffnen Impuls" 500 | }, 501 | "permanently_open_impulse": { 502 | "name": "Dauerhaft geöffnet Impuls" 503 | }, 504 | "reset_meter": { 505 | "name": "Messwerte zurücksetzen" 506 | }, 507 | "reset_meter_1": { 508 | "name": "Messwerte zurücksetzen 1" 509 | }, 510 | "reset_meter_2": { 511 | "name": "Messwerte zurücksetzen 2" 512 | }, 513 | "reset_meter_3": { 514 | "name": "Messwerte zurücksetzen 3" 515 | }, 516 | "reset_meter_4": { 517 | "name": "Messwerte zurücksetzen 4" 518 | }, 519 | "restore_last_known_state": { 520 | "name": "Letzten Status wiederherstellen" 521 | }, 522 | "switch_type": { 523 | "name": "Schaltertyp" 524 | }, 525 | "ventilate_impulse": { 526 | "name": "Belüften Impuls" 527 | }, 528 | "watchdog_on_off": { 529 | "name": "Watchdog" 530 | } 531 | } 532 | }, 533 | "exceptions": { 534 | "no_integer": { 535 | "message": "{service_attr} muss eine Ganzzahl sein." 536 | }, 537 | "no_float": { 538 | "message": "{service_attr} muss eine Zahl sein." 539 | }, 540 | "not_editable": { 541 | "message": "{entity} kann zur Zeit nicht verändert werden." 542 | } 543 | } 544 | } -------------------------------------------------------------------------------- /custom_components/homee/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "homee {name} ({host})", 4 | "abort": { 5 | "already_configured": "Device is already configured" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Invalid authentication", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Configure homee", 15 | "description": "Manually enter the ip address of the homee you want to connect.", 16 | "data": { 17 | "host": "Host", 18 | "password": "Password", 19 | "username": "Username", 20 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity" 21 | } 22 | }, 23 | "zeroconf_confirm": { 24 | "title": "Configure homee", 25 | "description": "Discovered homee {id} at {host}", 26 | "data": { 27 | "host": "Host", 28 | "password": "Password", 29 | "username": "Username", 30 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity." 31 | } 32 | }, 33 | "groups": { 34 | "title": "Group Configuration", 35 | "description": "Configure the groups. You can still change window & door groups later.", 36 | "data": { 37 | "import_groups": "Import devices in the following groups:", 38 | "window_groups": "Groups that contain window sensors:", 39 | "door_groups": "Groups that contain door sensors:" 40 | } 41 | }, 42 | "reconfigure": { 43 | "title": "Reconfigure homee", 44 | "description": "Change settings of homee.", 45 | "data": { 46 | "host": "Host", 47 | "password": "Password", 48 | "username": "Username", 49 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity" 50 | } 51 | } 52 | } 53 | }, 54 | "options": { 55 | "step": { 56 | "init": { 57 | "description": "Configure the homee integration. You may need to restart Home Assistant to apply the changes.", 58 | "data": { 59 | "window_groups": "Groups that contain window sensors:", 60 | "door_groups": "Groups that contain door sensors:", 61 | "add_homee_data": "Add (debug) information about the homee node and attributes to each entity." 62 | } 63 | } 64 | } 65 | }, 66 | "services": { 67 | "set_value": { 68 | "name": "Set Value", 69 | "description": "Set an attribute value of a homee node.", 70 | "fields": { 71 | "config_entry_id": { 72 | "name": "Target Homee", 73 | "description": "Homee on which the action will be executed." 74 | }, 75 | "node": { 76 | "name": "Node", 77 | "description": "The node ID." 78 | }, 79 | "attribute": { 80 | "name": "Attribute", 81 | "description": "The attribute ID." 82 | }, 83 | "value": { 84 | "name": "Value", 85 | "description": "The value to set." 86 | } 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "alarm_control_panel": { 92 | "homee_status": { 93 | "name": "Status", 94 | "state": { 95 | "armed_home": "Home", 96 | "armed_night": "Sleeping", 97 | "armed_away": "Away", 98 | "armed_vacation": "Vacation" 99 | } 100 | } 101 | }, 102 | "binary_sensor": { 103 | "battery_low": { 104 | "name": "Battery Low" 105 | }, 106 | "blackout_alarm": { 107 | "name": "Blackout" 108 | }, 109 | "carbon_dioxide": { 110 | "name": "Carbon dioxide alarm" 111 | }, 112 | "door": { 113 | "name": "Door" 114 | }, 115 | "fertilize_plant": { 116 | "name": "Fertilize plant" 117 | }, 118 | "flood": { 119 | "name": "Flood" 120 | }, 121 | "heat": { 122 | "name": "Temperature" 123 | }, 124 | "leak_alarm": { 125 | "name": "Leak" 126 | }, 127 | "load_alarm": { 128 | "name": "Load" 129 | }, 130 | "lock": { 131 | "name": "Lock" 132 | }, 133 | "low_temperature_alarm": { 134 | "name": "Low temperature" 135 | }, 136 | "malfunction": { 137 | "name": "Malfunction" 138 | }, 139 | "maximum": { 140 | "name": "Maximum alarm" 141 | }, 142 | "minimum_alarm": { 143 | "name": "Minimum alarm" 144 | }, 145 | "motion": { 146 | "name": "Motion" 147 | }, 148 | "motor_blocked_alarm": { 149 | "name": "Motor blocked" 150 | }, 151 | "opening": { 152 | "name": "Opening" 153 | }, 154 | "overcurrent": { 155 | "name": "Overcurrent" 156 | }, 157 | "overload": { 158 | "name": "Overload" 159 | }, 160 | "power_supply_alarm": { 161 | "name": "Power supply" 162 | }, 163 | "plug": { 164 | "name": "Plug" 165 | }, 166 | "rain": { 167 | "name": "Rain" 168 | }, 169 | "replace_filter": { 170 | "name": "Replace filter" 171 | }, 172 | "smoke": { 173 | "name": "Smoke" 174 | }, 175 | "storage_alarm": { 176 | "name": "Storage" 177 | }, 178 | "surge": { 179 | "name": "Surge" 180 | }, 181 | "tamper": { 182 | "name": "Tamper" 183 | }, 184 | "voltage_drop": { 185 | "name": "Voltage Drop" 186 | }, 187 | "window": { 188 | "name": "Window" 189 | }, 190 | "water_alarm": { 191 | "name": "Water" 192 | } 193 | }, 194 | "climate": { 195 | "homee": { 196 | "state_attributes": { 197 | "preset_mode": { 198 | "state": { 199 | "manual": "Manual" 200 | } 201 | } 202 | } 203 | } 204 | }, 205 | "event": { 206 | "up_down_remote": { 207 | "name": "Up Down Remote", 208 | "state_attributes": { 209 | "event_type": { 210 | "state": { 211 | "0": "None", 212 | "1": "Up", 213 | "2": "Down", 214 | "3": "Stop", 215 | "4": "Up (long press)", 216 | "5": "Down (long press)", 217 | "6": "Stop (long press)", 218 | "7": "C-Button", 219 | "9": "A-Button" 220 | } 221 | } 222 | } 223 | } 224 | }, 225 | "fan": { 226 | "homee": { 227 | "state_attributes": { 228 | "preset_mode": { 229 | "state": { 230 | "manual": "Manual", 231 | "auto": "Automatic", 232 | "summer": "Summer" 233 | } 234 | } 235 | } 236 | } 237 | }, 238 | "light": { 239 | "light_instance": { 240 | "name": "Light {instance}" 241 | } 242 | }, 243 | "number": { 244 | "number_button_brightness_active": { 245 | "name": "Button brightness (active)" 246 | }, 247 | "number_button_brightness_dimmed": { 248 | "name": "Button brightness (dimmed)" 249 | }, 250 | "number_display_brightness_active": { 251 | "name": "Display brightness (active)" 252 | }, 253 | "number_display_brightness_dimmed": { 254 | "name": "Display brightness (dimmed)" 255 | }, 256 | "number_down_position": { 257 | "name": "Down Position" 258 | }, 259 | "number_down_slat_position": { 260 | "name": "Down Slat Position" 261 | }, 262 | "number_down_time": { 263 | "name": "Down-movement duration" 264 | }, 265 | "number_endposition_configuration": { 266 | "name": "End position" 267 | }, 268 | "number_external_temperature_offset": { 269 | "name": "External temperature offset" 270 | }, 271 | "number_floor_temperature_offset": { 272 | "name": "Floor temperature offset" 273 | }, 274 | "number_motion_alarm_cancelation_delay": { 275 | "name": "Motion alarm Delay" 276 | }, 277 | "number_open_window_detection_sensibility": { 278 | "name": "Window Open Sensibility" 279 | }, 280 | "number_polling_interval": { 281 | "name": "Polling Interval" 282 | }, 283 | "number_shutter_slat_time": { 284 | "name": "Slat Turn Duration" 285 | }, 286 | "number_slat_max_angle": { 287 | "name": "Maximum Slat Angle" 288 | }, 289 | "number_slat_min_angle": { 290 | "name": "Minimum Slat Angle" 291 | }, 292 | "number_slat_steps": { 293 | "name": "Slat Steps" 294 | }, 295 | "number_target_temperature": { 296 | "name": "Target Temperature" 297 | }, 298 | "number_temperature_offset": { 299 | "name": "Temperature Offset" 300 | }, 301 | "number_temperature_report_interval": { 302 | "name": "Temperature report interval" 303 | }, 304 | "number_up_time": { 305 | "name": "Up-movement duration" 306 | }, 307 | "number_valve_position": { 308 | "name": "Valve Position" 309 | }, 310 | "number_wake_up_interval": { 311 | "name": "Wake-Up Interval" 312 | }, 313 | "number_wind_monitoring_state": { 314 | "name": "Wind Monitoring State" 315 | } 316 | }, 317 | "select": { 318 | "display_temperature_selection": { 319 | "name": "Displayed temperature", 320 | "state": { 321 | "selected": "target", 322 | "current": "measured" 323 | } 324 | }, 325 | "repeater_mode": { 326 | "name": "EnOcean repeater mode", 327 | "state": { 328 | "off": "Off", 329 | "level1": "Level 1", 330 | "level2": "Level 2" 331 | } 332 | } 333 | }, 334 | "sensor": { 335 | "brightness_instance": { 336 | "name": "Illuminance {instance}" 337 | }, 338 | "button_state": { 339 | "name": "Button state" 340 | }, 341 | "button_state_instance": { 342 | "name": "Button state {instance}" 343 | }, 344 | "current_instance": { 345 | "name": "Current {instance}" 346 | }, 347 | "dawn": { 348 | "name": "Dawn" 349 | }, 350 | "device_temperature": { 351 | "name": "Device temperature" 352 | }, 353 | "energy_instance": { 354 | "name": "Energy {instance}" 355 | }, 356 | "exhaust_motor_revs": { 357 | "name": "Exhaust motor speed" 358 | }, 359 | "external_temperature": { 360 | "name": "External temperature" 361 | }, 362 | "floor_temperature": { 363 | "name": "Floor temperature" 364 | }, 365 | "indoor_humidity": { 366 | "name": "Indoor humidity" 367 | }, 368 | "indoor_humidity_instance": { 369 | "name": "Indoor humidity {instance}" 370 | }, 371 | "indoor_temperature": { 372 | "name": "Indoor temperature" 373 | }, 374 | "indoor_temperature_instance": { 375 | "name": "Indoor temperature {instance}" 376 | }, 377 | "intake_motor_revs": { 378 | "name": "Intake motor speed" 379 | }, 380 | "level": { 381 | "name": "Level" 382 | }, 383 | "link_quality": { 384 | "name": "Link quality" 385 | }, 386 | "node_state": { 387 | "name": "Node state" 388 | }, 389 | "operating_hours": { 390 | "name": "Operating hours" 391 | }, 392 | "outdoor_humidity": { 393 | "name": "Outdoor humidity" 394 | }, 395 | "outdoor_humidity_instance": { 396 | "name": "Outdoor humidity {instance}" 397 | }, 398 | "outdoor_temperature": { 399 | "name": "Outdoor temperature" 400 | }, 401 | "outdoor_temperature_instance": { 402 | "name": "Outdoor temperature {instance}" 403 | }, 404 | "position": { 405 | "name": "Position" 406 | }, 407 | "power_instance": { 408 | "name": "Power {instance}" 409 | }, 410 | "rainfall_day": { 411 | "name": "Rainfall today" 412 | }, 413 | "rainfall_hour": { 414 | "name": "Rainfall last hour" 415 | }, 416 | "total_current": { 417 | "name": "Total current" 418 | }, 419 | "total_energy": { 420 | "name": "Total energy" 421 | }, 422 | "total_power": { 423 | "name": "Total power" 424 | }, 425 | "total_voltage": { 426 | "name": "Total voltage" 427 | }, 428 | "up_down": { 429 | "name": "State", 430 | "state": { 431 | "closed": "Closed", 432 | "closing": "Closing", 433 | "open": "Open", 434 | "opening": "Opening", 435 | "partial": "Partially open" 436 | } 437 | }, 438 | "uv": { 439 | "name": "Ultraviolet" 440 | }, 441 | "valve_position": { 442 | "name": "Valve position" 443 | }, 444 | "voltage_instance": { 445 | "name": "Voltage {instance}" 446 | }, 447 | "wake_up_interval": { 448 | "name": "Wake-Up Interval" 449 | }, 450 | "window_position": { 451 | "name": "Window position", 452 | "state": { 453 | "closed": "Closed", 454 | "open": "Open", 455 | "tilted": "Tilted" 456 | } 457 | } 458 | }, 459 | "switch": { 460 | "automatic_mode_impulse": { 461 | "name": "Automatic mode impulse" 462 | }, 463 | "briefly_open_impulse": { 464 | "name": "Briefly open impulse" 465 | }, 466 | "external_binary_input": { 467 | "name": "Child lock" 468 | }, 469 | "identification_mode": { 470 | "name": "Identification mode" 471 | }, 472 | "impulse": { 473 | "name": "Impulse" 474 | }, 475 | "impulse_1": { 476 | "name": "Impulse 1" 477 | }, 478 | "impulse_2": { 479 | "name": "Impulse 2" 480 | }, 481 | "impulse_3": { 482 | "name": "Impulse 3" 483 | }, 484 | "impulse_4": { 485 | "name": "Impulse 4" 486 | }, 487 | "light_impulse": { 488 | "name": "Light impulse" 489 | }, 490 | "light_impulse_1": { 491 | "name": "Light impulse 1" 492 | }, 493 | "light_impulse_2": { 494 | "name": "Light impulse 2" 495 | }, 496 | "light_impulse_3": { 497 | "name": "Light impulse 3" 498 | }, 499 | "light_impulse_4": { 500 | "name": "Light impulse 4" 501 | }, 502 | "manual_operation": { 503 | "name": "Manual operation" 504 | }, 505 | "motor_rotation": { 506 | "name": "Motor rotation direction" 507 | }, 508 | "open_partial_impulse": { 509 | "name": "Open partial impulse" 510 | }, 511 | "permanently_open_impulse": { 512 | "name": "Permanently open impulse" 513 | }, 514 | "reset_meter": { 515 | "name": "Reset meter" 516 | }, 517 | "reset_meter_1": { 518 | "name": "Reset meter 1" 519 | }, 520 | "reset_meter_2": { 521 | "name": "Reset meter 2" 522 | }, 523 | "reset_meter_3": { 524 | "name": "Reset meter 3" 525 | }, 526 | "reset_meter_4": { 527 | "name": "Reset meter 4" 528 | }, 529 | "restore_last_known_state": { 530 | "name": "Restore last known state" 531 | }, 532 | "switch_type": { 533 | "name": "Switch type" 534 | }, 535 | "ventilate_impulse": { 536 | "name": "Ventilate impulse" 537 | }, 538 | "watchdog_on_off": { 539 | "name": "Watchdog" 540 | } 541 | } 542 | }, 543 | "exceptions": { 544 | "no_integer": { 545 | "message": "{service_attr} must be an integer." 546 | }, 547 | "no_float": { 548 | "message": "{service_attr} must be a number." 549 | }, 550 | "not_editable": { 551 | "message": "{entity} is currently not changeable." 552 | } 553 | } 554 | } --------------------------------------------------------------------------------