├── .github ├── FUNDING.yml └── workflows │ ├── quality.yml │ └── release.yml ├── .gitignore ├── .releaserc ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── custom_components └── device_tools │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── device_tools.py │ ├── manifest.json │ ├── models.py │ └── translations │ ├── de.json │ └── en.json ├── hacs.json ├── pyproject.toml ├── tests ├── __init__.py ├── test_device_tools.py └── test_dummy.py └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: EuleMitKeule 4 | custom: paypal.me/lennardbeers 5 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: 7 | - opened 8 | - synchronize 9 | - reopened 10 | push: 11 | branches: 12 | - master 13 | 14 | env: 15 | SONAR_PROJECT_KEY: EuleMitKeule_device-tools 16 | SONAR_PROJECT_ORGANIZATION: eule 17 | 18 | jobs: 19 | cache: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | python-version: 24 | - "3.12" 25 | - "3.13" 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | enable-cache: true 35 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 36 | 37 | - name: Install dependecies 38 | run: uv sync --all-groups 39 | 40 | ruff: 41 | needs: cache 42 | runs-on: ubuntu-latest 43 | strategy: 44 | matrix: 45 | python-version: 46 | - "3.12" 47 | - "3.13" 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | 52 | - name: Install uv 53 | uses: astral-sh/setup-uv@v4 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | enable-cache: true 57 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 58 | 59 | - name: Install dependecies 60 | run: uv sync --all-groups 61 | 62 | - name: Run ruff 63 | run: | 64 | uv run ruff check . 65 | 66 | mypy: 67 | needs: cache 68 | runs-on: ubuntu-latest 69 | strategy: 70 | matrix: 71 | python-version: 72 | - "3.12" 73 | - "3.13" 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v4 77 | 78 | - name: Install uv 79 | uses: astral-sh/setup-uv@v4 80 | with: 81 | python-version: ${{ matrix.python-version }} 82 | enable-cache: true 83 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 84 | 85 | - name: Install dependecies 86 | run: uv sync --all-groups 87 | 88 | - name: Run mypy 89 | run: | 90 | uv run mypy --strict . 91 | 92 | tests: 93 | needs: cache 94 | runs-on: ubuntu-latest 95 | strategy: 96 | matrix: 97 | python-version: 98 | - "3.12" 99 | - "3.13" 100 | steps: 101 | - name: Checkout 102 | uses: actions/checkout@v4 103 | 104 | - name: Install uv 105 | uses: astral-sh/setup-uv@v4 106 | with: 107 | python-version: ${{ matrix.python-version }} 108 | enable-cache: true 109 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 110 | 111 | - name: Install dependecies 112 | run: uv sync --all-groups 113 | 114 | - name: Run tests 115 | run: | 116 | uv run pytest --cov=. --cov-report=term tests 117 | 118 | sonar: 119 | needs: cache 120 | runs-on: ubuntu-latest 121 | strategy: 122 | matrix: 123 | python-version: 124 | - "3.12" 125 | - "3.13" 126 | steps: 127 | - name: Checkout 128 | uses: actions/checkout@v4 129 | with: 130 | fetch-depth: 0 131 | 132 | - name: Install uv 133 | uses: astral-sh/setup-uv@v4 134 | with: 135 | python-version: ${{ matrix.python-version }} 136 | enable-cache: true 137 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 138 | 139 | - name: Install dependecies 140 | run: uv sync --all-groups 141 | 142 | - name: Run tests 143 | run: | 144 | uv run pytest --cov=. --cov-report=xml --cov-report=term tests 145 | 146 | - name: fix code coverage paths 147 | run: | 148 | sed -i 's|/home/runner/work/device-tools/device-tools|/github/workspace|g' coverage.xml 149 | 150 | - name: Run SonarCloud analysis 151 | uses: sonarsource/sonarcloud-github-action@master 152 | env: 153 | GITHUB_TOKEN: ${{ secrets.PAT }} 154 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 155 | with: 156 | args: > 157 | -Dsonar.organization=${{ env.SONAR_PROJECT_ORGANIZATION }} 158 | -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} 159 | -Dsonar.python.coverage.reportPaths=coverage.xml 160 | -Dsonar.sources=custom_components/device_tools 161 | -Dsonar.tests=tests 162 | -Dsonar.python.version=${{ matrix.python-version }} 163 | -Dsonar.text.inclusions=**/*.py 164 | 165 | 166 | hacs: 167 | needs: cache 168 | runs-on: ubuntu-latest 169 | strategy: 170 | matrix: 171 | python-version: 172 | - "3.12" 173 | - "3.13" 174 | steps: 175 | - name: Checkout 176 | uses: actions/checkout@v4 177 | 178 | - name: Install uv 179 | uses: astral-sh/setup-uv@v4 180 | with: 181 | python-version: ${{ matrix.python-version }} 182 | enable-cache: true 183 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 184 | 185 | - name: Install dependecies 186 | run: uv sync --all-groups 187 | 188 | - name: Run HACS 189 | uses: "hacs/action@main" 190 | with: 191 | category: "integration" 192 | 193 | hassfest: 194 | needs: cache 195 | runs-on: ubuntu-latest 196 | strategy: 197 | matrix: 198 | python-version: 199 | - "3.12" 200 | - "3.13" 201 | steps: 202 | - name: Checkout 203 | uses: actions/checkout@v4 204 | 205 | - name: Install uv 206 | uses: astral-sh/setup-uv@v4 207 | with: 208 | python-version: ${{ matrix.python-version }} 209 | enable-cache: true 210 | cache-suffix: uv-${{ runner.os }}-${{ matrix.python-version }} 211 | 212 | - name: Install dependecies 213 | run: uv sync --all-groups 214 | 215 | - name: Run Hassfest 216 | uses: home-assistant/actions/hassfest@master 217 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | semantic-release: 8 | runs-on: ubuntu-latest 9 | concurrency: release 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | persist-credentials: false 16 | 17 | - name: Semantic Release 18 | uses: cycjimmy/semantic-release-action@v3 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.PAT }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | custom_components/dummy/ 163 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "repository_url": "https://github.com/eulemitkeule/home-automations.git", 3 | "branches": [ 4 | "master" 5 | ], 6 | "plugins": [ 7 | "@semantic-release/commit-analyzer", 8 | "@semantic-release/release-notes-generator", 9 | "@semantic-release/github" 10 | ], 11 | "tagFormat": "${version}" 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestEnabled": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 Lennard Beers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![My Home Assistant](https://img.shields.io/badge/Home%20Assistant-%2341BDF5.svg?style=flat&logo=home-assistant&label=My)](https://my.home-assistant.io/redirect/hacs_repository/?owner=EuleMitKeule&repository=device-tools&category=integration) 2 | 3 | ![GitHub License](https://img.shields.io/github/license/eulemitkeule/device-tools) 4 | ![GitHub Sponsors](https://img.shields.io/github/sponsors/eulemitkeule?logo=GitHub-Sponsors) 5 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=EuleMitKeule_device-tools&metric=coverage)](https://sonarcloud.io/summary/new_code?id=EuleMitKeule_device-tools) 6 | 7 | [![Code Quality](https://github.com/EuleMitKeule/device-tools/actions/workflows/quality.yml/badge.svg)](https://github.com/EuleMitKeule/device-tools/actions/workflows/quality.yml) 8 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=EuleMitKeule_device-tools&metric=bugs)](https://sonarcloud.io/summary/new_code?id=EuleMitKeule_device-tools) 9 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=EuleMitKeule_device-tools&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=EuleMitKeule_device-tools) 10 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=EuleMitKeule_device-tools&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=EuleMitKeule_device-tools) 11 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=EuleMitKeule_device-tools&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=EuleMitKeule_device-tools) 12 | 13 | # Device tools for Home Assistant 14 | 15 | A custom Home Assistant integration that allows you to modify and interact with devices. 16 | 17 | > [!CAUTION] 18 | > This integration is still in development and has the potential to permanently modify your configuration in a bad way. 19 | 20 | ## Known Issues 21 | 22 | * Some entities seem to not be configurable. (see [#4](https://github.com/EuleMitKeule/device-tools/issues/4) and [#6](https://github.com/EuleMitKeule/device-tools/issues/6)) 23 | * Changing a modified virtual device's area requires a HA restart for the entities assigned to the device to reappear. (see [#20](https://github.com/EuleMitKeule/device-tools/issues/20)) 24 | * Reverting certain modifications like assigning entities to virtual devices does not work in some situations. (see [#22](https://github.com/EuleMitKeule/device-tools/issues/20)) 25 | 26 | ## Roadmap 27 | 28 | The integration will allow the user to... 29 | 30 | * [x] Modify static device attributes 31 | * [x] Assign entities to devices 32 | * [x] Create new devices 33 | * [x] Merge devices 34 | * [x] Automatically revert any modification on removal 35 | 36 | # Installation 37 | 38 | 1. Install the [HACS](https://hacs.xyz/) integration 39 | 2. Click the My Home Assistant link at the top of the readme 40 | -------------------------------------------------------------------------------- /custom_components/device_tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Device tools for Home Assistant.""" 2 | 3 | from __future__ import annotations 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import config_validation as cv 9 | 10 | from .const import DOMAIN 11 | from .device_tools import DeviceTools 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 16 | 17 | 18 | async def async_setup(hass: HomeAssistant, config: dict) -> bool: 19 | """Set up the Device Tools component.""" 20 | 21 | device_tools = DeviceTools(hass, _LOGGER) 22 | hass.data[DOMAIN] = device_tools 23 | 24 | return True 25 | 26 | 27 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 28 | """Set up the from a config entry.""" 29 | 30 | device_tools: DeviceTools = hass.data[DOMAIN] 31 | device_tools.async_get_entries(add_entry=config_entry) 32 | await device_tools.async_update() 33 | 34 | return True 35 | 36 | 37 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 38 | """Handle config entry update.""" 39 | 40 | await hass.config_entries.async_reload(entry.entry_id) 41 | 42 | device_tools: DeviceTools = hass.data[DOMAIN] 43 | device_tools.async_get_entries() 44 | await device_tools.async_update() 45 | 46 | 47 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 48 | """Handle config entry unload.""" 49 | 50 | device_tools: DeviceTools = hass.data[DOMAIN] 51 | device_tools.async_get_entries(remove_entry=config_entry) 52 | await device_tools.async_update() 53 | 54 | return True 55 | -------------------------------------------------------------------------------- /custom_components/device_tools/config_flow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import voluptuous as vol 6 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow 7 | from homeassistant.core import HomeAssistant, callback 8 | from homeassistant.data_entry_flow import FlowResult 9 | from homeassistant.exceptions import HomeAssistantError 10 | from homeassistant.helpers.device_registry import ( 11 | DeviceEntry, 12 | DeviceRegistry, 13 | ) 14 | from homeassistant.helpers.device_registry import ( 15 | async_get as async_get_device_registry, 16 | ) 17 | from homeassistant.helpers.entity_registry import ( 18 | EntityRegistry, 19 | async_entries_for_device, 20 | ) 21 | from homeassistant.helpers.entity_registry import ( 22 | async_get as async_get_entity_registry, 23 | ) 24 | from homeassistant.helpers.selector import ( 25 | ConstantSelector, 26 | ConstantSelectorConfig, 27 | SelectOptionDict, 28 | SelectSelector, 29 | SelectSelectorConfig, 30 | SelectSelectorMode, 31 | ) 32 | 33 | from .const import ( 34 | CONF_DEVICE_ID, 35 | CONF_DEVICE_NAME, 36 | CONF_DEVICES, 37 | CONF_ENTITIES, 38 | CONF_HW_VERSION, 39 | CONF_MANUFACTURER, 40 | CONF_MODEL, 41 | CONF_MODIFICATION_NAME, 42 | CONF_MODIFICATION_TYPE, 43 | CONF_SERIAL_NUMBER, 44 | CONF_SW_VERSION, 45 | CONF_VIA_DEVICE, 46 | DOMAIN, 47 | ModificationType, 48 | ) 49 | from .models import ( 50 | AttributeModification, 51 | DeviceModification, 52 | DeviceToolsConfigEntryData, 53 | EntityModification, 54 | MergeModification, 55 | ) 56 | 57 | _LOGGER = logging.getLogger(__name__) 58 | 59 | 60 | def _schema_attributes( 61 | hass: HomeAssistant, attribute_modification: AttributeModification 62 | ) -> vol.Schema: 63 | """Return the attributes schema.""" 64 | 65 | dr = async_get_device_registry(hass) 66 | 67 | config_entries = hass.config_entries.async_entries(DOMAIN) 68 | device_ids: set[str] = { 69 | entry.data["device_modification"]["device_id"] for entry in config_entries 70 | } 71 | 72 | return vol.Schema( 73 | { 74 | vol.Optional( 75 | CONF_MANUFACTURER, 76 | description={"suggested_value": attribute_modification["manufacturer"]}, 77 | ): str, 78 | vol.Optional( 79 | CONF_MODEL, 80 | description={"suggested_value": attribute_modification["model"]}, 81 | ): str, 82 | vol.Optional( 83 | CONF_VIA_DEVICE, 84 | description={ 85 | "suggested_value": attribute_modification["via_device_id"] 86 | }, 87 | ): SelectSelector( 88 | SelectSelectorConfig( 89 | options=[ 90 | SelectOptionDict( 91 | { 92 | "value": device.id, 93 | "label": device.name_by_user 94 | or device.name 95 | or device.id, 96 | } 97 | ) 98 | for device in dr.devices.values() 99 | if device.id not in device_ids and device.disabled_by is None 100 | ], 101 | mode=SelectSelectorMode.DROPDOWN, 102 | ) 103 | ), 104 | vol.Optional( 105 | CONF_SW_VERSION, 106 | description={"suggested_value": attribute_modification["sw_version"]}, 107 | ): str, 108 | vol.Optional( 109 | CONF_HW_VERSION, 110 | description={"suggested_value": attribute_modification["hw_version"]}, 111 | ): str, 112 | vol.Optional( 113 | CONF_SERIAL_NUMBER, 114 | description={ 115 | "suggested_value": attribute_modification["serial_number"] 116 | }, 117 | ): str, 118 | } 119 | ) 120 | 121 | 122 | def _schema_entities( 123 | hass: HomeAssistant, entity_modification: EntityModification 124 | ) -> vol.Schema: 125 | """Return the entities schema.""" 126 | 127 | er = async_get_entity_registry(hass) 128 | 129 | return vol.Schema( 130 | { 131 | vol.Optional( 132 | CONF_ENTITIES, 133 | description={"suggested_value": entity_modification["entities"]}, 134 | ): SelectSelector( 135 | SelectSelectorConfig( 136 | options=[ 137 | SelectOptionDict( 138 | { 139 | "value": entity.id, 140 | "label": entity.name or entity.entity_id, 141 | } 142 | ) 143 | for entity in er.entities.values() 144 | ], 145 | mode=SelectSelectorMode.DROPDOWN, 146 | multiple=True, 147 | ) 148 | ), 149 | } 150 | ) 151 | 152 | 153 | def _schema_merge( 154 | hass: HomeAssistant, device_id: str, merge_modification: MergeModification 155 | ) -> vol.Schema: 156 | """Return the merge schema.""" 157 | 158 | dr = async_get_device_registry(hass) 159 | 160 | return vol.Schema( 161 | { 162 | vol.Optional( 163 | CONF_DEVICES, 164 | description={"suggested_value": merge_modification["devices"]}, 165 | ): SelectSelector( 166 | SelectSelectorConfig( 167 | options=[ 168 | SelectOptionDict( 169 | { 170 | "value": other_device.id, 171 | "label": other_device.name_by_user 172 | or other_device.name 173 | or other_device.id, 174 | } 175 | ) 176 | for other_device in dr.devices.values() 177 | if other_device.id != device_id 178 | ], 179 | mode=SelectSelectorMode.DROPDOWN, 180 | multiple=True, 181 | ) 182 | ), 183 | } 184 | ) 185 | 186 | 187 | class DeviceToolsConfigFlow(ConfigFlow, domain=DOMAIN): 188 | """Device Tools config flow.""" 189 | 190 | def __init__(self) -> None: 191 | """Initialize the config flow.""" 192 | 193 | self._user_input_user: dict[str, Any] | None = None 194 | self._user_input_device: dict[str, Any] | None = None 195 | 196 | @property 197 | def device_registry(self) -> DeviceRegistry: 198 | """Return the device registry.""" 199 | 200 | return async_get_device_registry(self.hass) 201 | 202 | @property 203 | def entity_registry(self) -> EntityRegistry: 204 | """Return the entity registry.""" 205 | 206 | return async_get_entity_registry(self.hass) 207 | 208 | @property 209 | def user_input_user(self) -> dict[str, Any]: 210 | """Return the user input user.""" 211 | 212 | if TYPE_CHECKING: 213 | assert self._user_input_user is not None 214 | 215 | return self._user_input_user 216 | 217 | @user_input_user.setter 218 | def user_input_user(self, value: dict[str, Any]) -> None: 219 | """Set the user input user.""" 220 | 221 | self._user_input_user = value 222 | 223 | @property 224 | def user_input_device(self) -> dict[str, Any]: 225 | """Return the input device.""" 226 | 227 | if TYPE_CHECKING: 228 | assert self._user_input_device is not None 229 | 230 | return self._user_input_device 231 | 232 | @user_input_device.setter 233 | def user_input_device(self, value: dict[str, Any]) -> None: 234 | """Set the user input device.""" 235 | 236 | self._user_input_device = value 237 | 238 | @property 239 | def user_input_main(self) -> dict[str, Any]: 240 | """Return the user input main.""" 241 | 242 | if TYPE_CHECKING: 243 | assert self._user_input_main is not None 244 | 245 | return self._user_input_main 246 | 247 | @user_input_main.setter 248 | def user_input_main(self, value: dict[str, Any]) -> None: 249 | """Set the user input main.""" 250 | 251 | self._user_input_main = value 252 | 253 | async def async_step_user( 254 | self, user_input: dict[str, Any] | None = None 255 | ) -> FlowResult: 256 | """Handle a flow initialized by the user.""" 257 | 258 | other_entries = self._async_current_entries() 259 | other_device_ids: set[str] = { 260 | entry.data["device_modification"]["device_id"] for entry in other_entries 261 | } 262 | 263 | if user_input is None: 264 | return self.async_show_form( 265 | step_id="user", 266 | data_schema=vol.Schema( 267 | { 268 | vol.Required(CONF_MODIFICATION_NAME): str, 269 | vol.Optional(CONF_DEVICE_ID): SelectSelector( 270 | SelectSelectorConfig( 271 | options=[ 272 | SelectOptionDict( 273 | { 274 | "value": device.id, 275 | "label": device.name_by_user 276 | or device.name 277 | or device.id, 278 | } 279 | ) 280 | for device in sorted(list(self.device_registry.devices.values()),key=lambda x: x.name_by_user or x.name or x.id) 281 | if device.id not in other_device_ids 282 | and device.disabled_by is None 283 | ], 284 | mode=SelectSelectorMode.DROPDOWN, 285 | ) 286 | ), 287 | } 288 | ), 289 | ) 290 | 291 | self._user_input_user = user_input 292 | 293 | await self.async_set_unique_id(user_input[CONF_MODIFICATION_NAME]) 294 | self._abort_if_unique_id_configured(updates=user_input) 295 | 296 | return await self.async_step_device() 297 | 298 | async def async_step_device( 299 | self, user_input: dict[str, Any] | None = None 300 | ) -> FlowResult: 301 | """Handle the device step.""" 302 | 303 | device_id: str | None = self.user_input_user.get(CONF_DEVICE_ID) 304 | modification_name: str = self.user_input_user[CONF_MODIFICATION_NAME] 305 | 306 | if user_input is None and device_id is None: 307 | return self.async_show_form( 308 | step_id="device", 309 | data_schema=vol.Schema( 310 | { 311 | vol.Required(CONF_DEVICE_NAME): str, 312 | } 313 | ), 314 | ) 315 | elif device_id is not None: 316 | other_entries: list[ConfigEntry] = self._async_current_entries() 317 | entry_with_same_device: ConfigEntry | None = next( 318 | ( 319 | entry 320 | for entry in other_entries 321 | if entry.data["device_modification"]["device_id"] == device_id 322 | and device_id is not None 323 | ), 324 | None, 325 | ) 326 | 327 | if entry_with_same_device is not None: 328 | return self.async_abort(reason="already_configured") 329 | 330 | device = self.device_registry.async_get(device_id) 331 | 332 | if device is None: 333 | return self.async_abort(reason="device_not_found") 334 | 335 | if device.disabled_by is not None: 336 | return self.async_abort(reason="device_disabled") 337 | 338 | device_modification = DeviceModification( 339 | { 340 | "modification_name": modification_name, 341 | "device_id": device.id, 342 | "device_name": device.name_by_user or device.name or device.id, 343 | "attribute_modification": None, 344 | "entity_modification": None, 345 | "merge_modification": None, 346 | } 347 | ) 348 | elif user_input is not None: 349 | device_modification = DeviceModification( 350 | { 351 | "modification_name": modification_name, 352 | "device_id": None, 353 | "device_name": user_input[CONF_DEVICE_NAME], 354 | "attribute_modification": None, 355 | "entity_modification": None, 356 | "merge_modification": None, 357 | } 358 | ) 359 | 360 | return self.async_create_entry( 361 | title=device_modification["modification_name"], 362 | data=DeviceToolsConfigEntryData( 363 | { 364 | "device_modification": device_modification, 365 | } 366 | ), 367 | ) 368 | 369 | @staticmethod 370 | @callback 371 | def async_get_options_flow( 372 | config_entry: ConfigEntry, 373 | ) -> OptionsFlow: 374 | """Create the options flow.""" 375 | return OptionsFlowHandler(config_entry) 376 | 377 | 378 | class OptionsFlowHandler(OptionsFlow): 379 | def __init__(self, config_entry: ConfigEntry) -> None: 380 | """Initialize options flow.""" 381 | 382 | self.config_entry = config_entry 383 | self.device_modification: DeviceModification = config_entry.data[ 384 | "device_modification" 385 | ] 386 | 387 | @property 388 | def device_registry(self) -> DeviceRegistry: 389 | """Return the device registry.""" 390 | 391 | return async_get_device_registry(self.hass) 392 | 393 | @property 394 | def entity_registry(self) -> EntityRegistry: 395 | """Return the entity registry.""" 396 | 397 | return async_get_entity_registry(self.hass) 398 | 399 | @property 400 | def device(self) -> DeviceEntry: 401 | """Return the device.""" 402 | 403 | device_id: str | None = self.device_modification["device_id"] 404 | 405 | if device_id is None: 406 | raise HomeAssistantError("device_not_found") 407 | 408 | device = self.device_registry.async_get(device_id) 409 | 410 | if device is None: 411 | raise HomeAssistantError("device_not_found") 412 | 413 | return device 414 | 415 | async def async_step_init( 416 | self, user_input: dict[str, Any] | None = None 417 | ) -> FlowResult: 418 | """Manage the options.""" 419 | 420 | if user_input is None: 421 | return self.async_show_form( 422 | step_id="init", 423 | data_schema=vol.Schema( 424 | { 425 | vol.Optional( 426 | CONF_DEVICE_ID, 427 | ): ConstantSelector( 428 | ConstantSelectorConfig( 429 | label=f"Device: {self.device.name_by_user or self.device.name or self.device.id}", 430 | value="", 431 | ) 432 | ), 433 | vol.Required( 434 | CONF_MODIFICATION_TYPE, default=ModificationType.ATTRIBUTES 435 | ): vol.In( 436 | [value for value in ModificationType.__members__.values()] 437 | ), 438 | } 439 | ), 440 | ) 441 | 442 | modification_type: ModificationType = user_input[CONF_MODIFICATION_TYPE] 443 | 444 | match modification_type: 445 | case ModificationType.ATTRIBUTES: 446 | return await self.async_step_attributes() 447 | case ModificationType.ENTITIES: 448 | return await self.async_step_entities() 449 | case ModificationType.MERGE: 450 | return await self.async_step_merge() 451 | 452 | async def async_step_attributes( 453 | self, user_input: dict[str, Any] | None = None 454 | ) -> FlowResult: 455 | """Handle the attributes step.""" 456 | 457 | if self.device_modification["attribute_modification"] is None: 458 | self.device_modification["attribute_modification"] = AttributeModification( 459 | { 460 | "manufacturer": self.device.manufacturer, 461 | "model": self.device.model, 462 | "sw_version": self.device.sw_version, 463 | "hw_version": self.device.hw_version, 464 | "serial_number": self.device.serial_number, 465 | "via_device_id": self.device.via_device_id, 466 | } 467 | ) 468 | 469 | if TYPE_CHECKING: 470 | assert self.device_modification["attribute_modification"] is not None 471 | 472 | if user_input is None: 473 | return self.async_show_form( 474 | step_id="attributes", 475 | data_schema=_schema_attributes( 476 | self.hass, self.device_modification["attribute_modification"] 477 | ), 478 | ) 479 | 480 | self.device_modification["attribute_modification"] = AttributeModification( 481 | { 482 | "manufacturer": user_input.get(CONF_MANUFACTURER), 483 | "model": user_input.get(CONF_MODEL), 484 | "sw_version": user_input.get(CONF_SW_VERSION), 485 | "hw_version": user_input.get(CONF_HW_VERSION), 486 | "serial_number": user_input.get(CONF_SERIAL_NUMBER), 487 | "via_device_id": user_input.get(CONF_VIA_DEVICE), 488 | } 489 | ) 490 | 491 | return self.async_create_entry( 492 | title="", 493 | data=DeviceToolsConfigEntryData( 494 | { 495 | "device_modification": self.device_modification, 496 | } 497 | ), 498 | ) 499 | 500 | async def async_step_entities( 501 | self, user_input: dict[str, Any] | None = None 502 | ) -> FlowResult: 503 | """Handle the entities step.""" 504 | 505 | if self.device_modification["entity_modification"] is None: 506 | entities = async_entries_for_device(self.entity_registry, self.device.id) 507 | entity_ids = {entity.id for entity in entities} 508 | 509 | self.device_modification["entity_modification"] = EntityModification( 510 | { 511 | "entities": entity_ids, 512 | } 513 | ) 514 | 515 | if TYPE_CHECKING: 516 | assert self.device_modification["entity_modification"] is not None 517 | 518 | if user_input is None: 519 | return self.async_show_form( 520 | step_id="entities", 521 | data_schema=_schema_entities( 522 | self.hass, 523 | entity_modification=self.device_modification["entity_modification"], 524 | ), 525 | ) 526 | 527 | self.device_modification["entity_modification"] = EntityModification( 528 | { 529 | "entities": user_input[CONF_ENTITIES], 530 | } 531 | ) 532 | 533 | config_entries: list[ConfigEntry] = self.hass.config_entries.async_entries( 534 | DOMAIN 535 | ) 536 | for config_entry in config_entries: 537 | if config_entry.entry_id == self.config_entry.entry_id: 538 | continue 539 | 540 | other_device_modification: DeviceModification = config_entry.data[ 541 | "device_modification" 542 | ] 543 | 544 | other_entity_modification: EntityModification | None = ( 545 | other_device_modification.get("entity_modification") 546 | ) 547 | 548 | if other_entity_modification is None: 549 | continue 550 | 551 | other_entities = set(other_entity_modification["entities"]) 552 | own_entities = set(user_input[CONF_ENTITIES]) 553 | duplicate_entities = other_entities & own_entities 554 | 555 | if len(duplicate_entities) == 0: 556 | continue 557 | 558 | new_entities = other_entities - duplicate_entities 559 | 560 | other_device_modification["entity_modification"] = EntityModification( 561 | { 562 | "entities": new_entities, 563 | } 564 | ) 565 | 566 | self.hass.config_entries.async_update_entry( 567 | config_entry, 568 | data=DeviceToolsConfigEntryData( 569 | { 570 | "device_modification": other_device_modification, 571 | } 572 | ), 573 | ) 574 | 575 | return self.async_create_entry( 576 | title="", 577 | data=DeviceToolsConfigEntryData( 578 | { 579 | "device_modification": self.device_modification, 580 | } 581 | ), 582 | ) 583 | 584 | async def async_step_merge( 585 | self, user_input: dict[str, Any] | None = None 586 | ) -> FlowResult: 587 | """Handle the merge step.""" 588 | 589 | if self.device_modification["merge_modification"] is None: 590 | self.device_modification["merge_modification"] = MergeModification( 591 | { 592 | "devices": set(), 593 | } 594 | ) 595 | 596 | if TYPE_CHECKING: 597 | assert self.device_modification["merge_modification"] is not None 598 | 599 | if self.device_modification["device_id"] is None: 600 | return self.async_abort(reason="device_not_found") 601 | 602 | if user_input is None: 603 | return self.async_show_form( 604 | step_id="merge", 605 | data_schema=_schema_merge( 606 | self.hass, 607 | self.device_modification["device_id"], 608 | self.device_modification["merge_modification"], 609 | ), 610 | ) 611 | 612 | config_entries: list[ConfigEntry] = self.hass.config_entries.async_entries( 613 | DOMAIN 614 | ) 615 | 616 | for config_entry in config_entries: 617 | if config_entry.entry_id == self.config_entry.entry_id: 618 | continue 619 | 620 | own_devices = set(user_input[CONF_DEVICES]) 621 | 622 | other_device_modification: DeviceModification = config_entry.data[ 623 | "device_modification" 624 | ] 625 | 626 | if other_device_modification["device_id"] in own_devices: 627 | return self.async_abort(reason="device_in_use") 628 | 629 | other_merge_modification: MergeModification | None = ( 630 | other_device_modification.get("merge_modification") 631 | ) 632 | 633 | if other_merge_modification is None: 634 | continue 635 | 636 | other_devices = set(other_merge_modification["devices"]) 637 | duplicate_devices = other_devices & own_devices 638 | 639 | if len(duplicate_devices) > 0: 640 | return self.async_abort(reason="device_already_merged") 641 | 642 | self.device_modification["merge_modification"] = MergeModification( 643 | { 644 | "devices": user_input[CONF_DEVICES], 645 | } 646 | ) 647 | 648 | return self.async_create_entry( 649 | title="", 650 | data=DeviceToolsConfigEntryData( 651 | { 652 | "device_modification": self.device_modification, 653 | } 654 | ), 655 | ) 656 | -------------------------------------------------------------------------------- /custom_components/device_tools/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Device Tools integration.""" 2 | 3 | 4 | from enum import StrEnum 5 | 6 | DOMAIN = "device_tools" 7 | 8 | 9 | SCAN_INTERVAL = 5 10 | 11 | 12 | CONF_MODIFICATION_TYPE = "modification_type" 13 | CONF_MODIFICATION_NAME = "modification_name" 14 | CONF_DEVICE_ID = "device_id" 15 | CONF_MANUFACTURER = "manufacturer" 16 | CONF_MODEL = "model" 17 | CONF_VIA_DEVICE = "via_device_id" 18 | CONF_SW_VERSION = "sw_version" 19 | CONF_HW_VERSION = "hw_version" 20 | CONF_SERIAL_NUMBER = "serial_number" 21 | CONF_IDENTIFIERS = "identifiers" 22 | CONF_ENTITIES = "entities" 23 | CONF_DEVICE_NAME = "device_name" 24 | CONF_DEVICES = "devices" 25 | 26 | 27 | class ModificationType(StrEnum): 28 | """Modification type enum.""" 29 | 30 | ATTRIBUTES = "attributes" 31 | ENTITIES = "entities" 32 | MERGE = "merge" 33 | -------------------------------------------------------------------------------- /custom_components/device_tools/device_tools.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from copy import deepcopy 4 | from typing import TYPE_CHECKING 5 | 6 | from homeassistant.config_entries import ConfigEntry, ConfigEntryState 7 | from homeassistant.core import HomeAssistant, callback 8 | from homeassistant.exceptions import HomeAssistantError 9 | from homeassistant.helpers.device_registry import ( 10 | DeviceEntry, 11 | ) 12 | from homeassistant.helpers.device_registry import ( 13 | async_get as async_get_device_registry, 14 | ) 15 | from homeassistant.helpers.entity_registry import ( 16 | async_entries_for_device, 17 | ) 18 | from homeassistant.helpers.entity_registry import ( 19 | async_get as async_get_entity_registry, 20 | ) 21 | 22 | from .const import DOMAIN, SCAN_INTERVAL 23 | from .models import ( 24 | AttributeModification, 25 | DeviceModification, 26 | DeviceToolsData, 27 | EntityModification, 28 | MergeModification, 29 | ) 30 | 31 | 32 | class DeviceTools: 33 | """Device Tools class.""" 34 | 35 | def __init__(self, hass: HomeAssistant, logger: logging.Logger) -> None: 36 | """Initialize.""" 37 | 38 | self._hass = hass 39 | self._logger = logger 40 | self._device_registry = async_get_device_registry(hass) 41 | self._entity_registry = async_get_entity_registry(hass) 42 | self._device_modifications: dict[str, DeviceModification] = {} 43 | self._previous_device_modifications: dict[str, DeviceModification] = {} 44 | self._device_tools_data: DeviceToolsData = { 45 | "original_entity_configs": {}, 46 | "original_device_configs": {}, 47 | } 48 | self._run_task = hass.async_create_background_task(self.async_run(), DOMAIN) 49 | 50 | @callback 51 | def async_get_entries( 52 | self, 53 | add_entry: ConfigEntry | None = None, 54 | remove_entry: ConfigEntry | None = None, 55 | ) -> None: 56 | """Handle config entry changes.""" 57 | 58 | config_entries = self._hass.config_entries.async_entries(DOMAIN) 59 | 60 | if add_entry is not None and add_entry not in config_entries: 61 | config_entries.append(add_entry) 62 | 63 | if remove_entry is not None and remove_entry in config_entries: 64 | config_entries.remove(remove_entry) 65 | 66 | device_modifications = { 67 | config_entry.entry_id: config_entry.data["device_modification"] 68 | for config_entry in config_entries 69 | if config_entry.state == ConfigEntryState.LOADED 70 | } 71 | 72 | if self._device_modifications == device_modifications: 73 | return 74 | 75 | self._previous_device_modifications = deepcopy(self._device_modifications) 76 | self._device_modifications = deepcopy(device_modifications) 77 | 78 | @callback 79 | async def async_run(self): 80 | """Run the background task.""" 81 | 82 | while True: 83 | try: 84 | self.async_get_entries() 85 | await self.async_update() 86 | except Exception as e: # pylint: disable=broad-except 87 | self._logger.exception(e) 88 | 89 | await asyncio.sleep(SCAN_INTERVAL) 90 | 91 | async def _async_validate(self) -> None: 92 | """Validate device modifications.""" 93 | 94 | validated_devices: set[str] = set() 95 | validated_device_modifications: dict[str, DeviceModification] = {} 96 | 97 | for entry_id, device_modification in self._device_modifications.items(): 98 | device_id = device_modification["device_id"] 99 | device: DeviceEntry | None = None 100 | 101 | if device_id is None: 102 | device = self._device_registry.async_get_or_create( 103 | config_entry_id=entry_id, 104 | name=device_modification["device_name"], 105 | identifiers={(DOMAIN, entry_id)}, 106 | ) 107 | device_modification["device_id"] = device.id 108 | device_id = device.id 109 | else: 110 | device = self._device_registry.async_get(device_id) 111 | 112 | if device is None: 113 | self._logger.error( 114 | "[%s] Device not found (name: %s)", 115 | device_modification["modification_name"], 116 | device_modification["device_name"], 117 | ) 118 | continue 119 | 120 | if device_id in validated_devices: 121 | self._logger.error( 122 | "[%s] Device is already a parent device (id: %s)", 123 | device_modification["modification_name"], 124 | device_modification["device_id"], 125 | ) 126 | continue 127 | 128 | if entry_id in validated_device_modifications: 129 | self._logger.error( 130 | "[%s] Duplicate config entry (id: %s)", 131 | device_modification["modification_name"], 132 | entry_id, 133 | ) 134 | 135 | config_entry = self._hass.config_entries.async_get_entry(entry_id) 136 | 137 | if config_entry is None: 138 | self._logger.error( 139 | "[%s] Config entry not found (id: %s)", 140 | device_modification["modification_name"], 141 | entry_id, 142 | ) 143 | continue 144 | 145 | validated_devices.add(device.id) 146 | validated_device_modifications[entry_id] = device_modification 147 | 148 | self._hass.config_entries.async_update_entry( 149 | config_entry, 150 | data={ 151 | **config_entry.data, 152 | "device_modification": device_modification, 153 | }, 154 | ) 155 | 156 | await self._async_validate_device_modifications(validated_device_modifications) 157 | 158 | self._device_modifications = validated_device_modifications 159 | 160 | async def _async_validate_device_modifications( 161 | self, device_modifications: dict[str, DeviceModification] 162 | ) -> None: 163 | """Validate device modifications.""" 164 | 165 | devices: set[str] = { 166 | device_modification["device_id"] 167 | for config_entry_id, device_modification in device_modifications.items() 168 | if device_modification["device_id"] is not None 169 | } 170 | 171 | entities: set[str] = set() 172 | merged_devices: set[str] = set() 173 | 174 | for device_modification in device_modifications.values(): 175 | if device_modification["entity_modification"] is not None: 176 | unknown_entities: list[str] = [] 177 | duplicate_entities: list[str] = [] 178 | 179 | for entity_id in device_modification["entity_modification"]["entities"]: 180 | entity = self._entity_registry.async_get(entity_id) 181 | 182 | if entity is None: 183 | self._logger.warning( 184 | "[%s] Removing unknown entity (id: %s)", 185 | device_modification["modification_name"], 186 | entity_id, 187 | ) 188 | 189 | unknown_entities.append(entity_id) 190 | continue 191 | 192 | if entity_id in entities: 193 | self._logger.warning( 194 | "[%s] Removing duplicate entity (id: %s)", 195 | device_modification["modification_name"], 196 | entity_id, 197 | ) 198 | 199 | duplicate_entities.append(entity_id) 200 | continue 201 | 202 | entities.add(entity_id) 203 | 204 | for entity_id in unknown_entities + duplicate_entities: 205 | device_modification["entity_modification"]["entities"].remove( 206 | entity_id 207 | ) 208 | 209 | if device_modification["merge_modification"] is not None: 210 | unknown_devices: list[str] = [] 211 | duplicate_devices: list[str] = [] 212 | 213 | for merged_device_id in device_modification["merge_modification"][ 214 | "devices" 215 | ]: 216 | merged_device = self._device_registry.async_get(merged_device_id) 217 | 218 | if merged_device is None: 219 | self._logger.warning( 220 | "[%s] Removing unknown device (id: %s)", 221 | device_modification["modification_name"], 222 | merged_device_id, 223 | ) 224 | 225 | unknown_devices.append(merged_device_id) 226 | continue 227 | 228 | if merged_device_id in merged_devices: 229 | self._logger.warning( 230 | "[%s] Removing duplicate device (id: %s)", 231 | device_modification["modification_name"], 232 | merged_device_id, 233 | ) 234 | 235 | duplicate_devices.append(merged_device_id) 236 | continue 237 | 238 | if merged_device_id in devices: 239 | self._logger.warning( 240 | "[%s] Removing device that has a modification (id: %s)", 241 | device_modification["modification_name"], 242 | merged_device_id, 243 | ) 244 | 245 | merged_devices.add(merged_device_id) 246 | continue 247 | 248 | merged_devices.add(merged_device_id) 249 | 250 | for merged_device_id in unknown_devices + duplicate_devices: 251 | device_modification["merge_modification"]["devices"].remove( 252 | merged_device_id 253 | ) 254 | 255 | async def _async_save_original_device_config(self, device_id: str) -> None: 256 | """Save original device config.""" 257 | 258 | device = self._device_registry.async_get(device_id) 259 | 260 | if device is None: 261 | raise HomeAssistantError(f"Device not found (id: {device_id})") 262 | 263 | original_device_config = self._device_tools_data["original_device_configs"].get( 264 | device_id 265 | ) 266 | 267 | if original_device_config is None: 268 | self._device_tools_data["original_device_configs"][device_id] = { 269 | "manufacturer": device.manufacturer, 270 | "model": device.model, 271 | "sw_version": device.sw_version, 272 | "hw_version": device.hw_version, 273 | "serial_number": device.serial_number, 274 | "via_device_id": device.via_device_id, 275 | "config_entries": device.config_entries, 276 | "config_entries_set_by_device_tools": set(), 277 | } 278 | 279 | async def _async_save_original_entity_config(self, entity_id: str) -> None: 280 | """Save original entity config.""" 281 | 282 | entity = self._entity_registry.async_get(entity_id) 283 | 284 | if entity is None: 285 | raise HomeAssistantError(f"Entity not found (id: {entity_id})") 286 | 287 | original_entity_config = self._device_tools_data["original_entity_configs"].get( 288 | entity_id 289 | ) 290 | 291 | if original_entity_config is None: 292 | self._device_tools_data["original_entity_configs"][entity_id] = { 293 | "device_id": entity.device_id, 294 | } 295 | 296 | async def _async_add_config_entry(self, device_id: str, config_entry_id: str): 297 | """Add config entry for device.""" 298 | 299 | await self._async_save_original_device_config(device_id) 300 | 301 | original_device_config = self._device_tools_data["original_device_configs"].get( 302 | device_id 303 | ) 304 | 305 | if TYPE_CHECKING: 306 | assert original_device_config is not None 307 | 308 | original_device_config["config_entries_set_by_device_tools"].add( 309 | config_entry_id 310 | ) 311 | 312 | self._device_registry.async_update_device( 313 | device_id, 314 | add_config_entry_id=config_entry_id, 315 | ) 316 | 317 | async def _async_revert(self) -> None: 318 | """Revert removed modifications.""" 319 | 320 | for ( 321 | entry_id, 322 | device_modification, 323 | ) in self._previous_device_modifications.items(): 324 | if entry_id in self._device_modifications: 325 | continue 326 | 327 | device_id: str | None = device_modification["device_id"] 328 | 329 | if TYPE_CHECKING: 330 | assert device_id is not None 331 | 332 | await self._async_revert_device(device_id) 333 | 334 | entities = async_entries_for_device( 335 | self._entity_registry, 336 | device_id, 337 | include_disabled_entities=True, 338 | ) 339 | 340 | for entity in entities: 341 | await self._async_revert_entity(entity.id) 342 | 343 | if entity.id in self._device_tools_data["original_entity_configs"]: 344 | del self._device_tools_data["original_entity_configs"][entity.id] 345 | 346 | if device_id in self._device_tools_data["original_device_configs"]: 347 | del self._device_tools_data["original_device_configs"][device_id] 348 | 349 | async def _async_revert_entity(self, entity_id: str) -> None: 350 | """Revert entity modifications.""" 351 | 352 | entity = self._entity_registry.async_get(entity_id) 353 | 354 | if entity is None: 355 | raise HomeAssistantError(f"Entity not found (id: {entity_id})") 356 | 357 | original_entity_config = self._device_tools_data["original_entity_configs"].get( 358 | entity_id 359 | ) 360 | 361 | if original_entity_config is None: 362 | return 363 | 364 | self._entity_registry.async_update_entity( 365 | entity.entity_id, device_id=original_entity_config["device_id"] 366 | ) 367 | 368 | async def _async_revert_device(self, device_id: str) -> None: 369 | """Revert device modifications.""" 370 | 371 | device: DeviceEntry | None = self._device_registry.async_get(device_id) 372 | 373 | if device is None: 374 | raise HomeAssistantError(f"Device not found (id: {device_id})") 375 | 376 | original_device_config = self._device_tools_data["original_device_configs"].get( 377 | device_id 378 | ) 379 | 380 | if original_device_config is None: 381 | self._logger.warning( 382 | "[%s] Original device config not found (id: %s)", 383 | ) 384 | return 385 | 386 | self._device_registry.async_update_device( 387 | device.id, 388 | manufacturer=original_device_config["manufacturer"], 389 | model=original_device_config["model"], 390 | sw_version=original_device_config["sw_version"], 391 | hw_version=original_device_config["hw_version"], 392 | serial_number=original_device_config["serial_number"], 393 | via_device_id=original_device_config["via_device_id"], 394 | ) 395 | 396 | for config_entry_id in original_device_config["config_entries"]: 397 | if config_entry_id not in device.config_entries: 398 | self._device_registry.async_update_device( 399 | device.id, 400 | add_config_entry_id=config_entry_id, 401 | ) 402 | 403 | for config_entry_id in device.config_entries: 404 | if ( 405 | config_entry_id 406 | in original_device_config["config_entries_set_by_device_tools"] 407 | and config_entry_id not in original_device_config["config_entries"] 408 | ): 409 | self._device_registry.async_update_device( 410 | device.id, 411 | remove_config_entry_id=config_entry_id, 412 | ) 413 | 414 | @callback 415 | async def async_update(self) -> None: 416 | """Update devices.""" 417 | 418 | await self._async_revert() 419 | self._previous_device_modifications = deepcopy(self._device_modifications) 420 | 421 | if len(self._device_modifications) == 0: 422 | return 423 | 424 | await self._async_validate() 425 | 426 | for entry_id, device_modification in self._device_modifications.items(): 427 | if TYPE_CHECKING: 428 | assert device_modification["device_id"] is not None 429 | 430 | device: DeviceEntry | None = self._device_registry.async_get( 431 | device_modification["device_id"] 432 | ) 433 | 434 | if device is None: 435 | self._logger.error( 436 | "[%s] Device not found (id: %s)", 437 | device_modification["device_name"], 438 | device_modification["device_id"], 439 | ) 440 | continue 441 | 442 | await self._async_save_original_device_config(device.id) 443 | 444 | if device_modification["attribute_modification"] is not None: 445 | await self._async_apply_attribute_modification( 446 | device, device_modification["attribute_modification"] 447 | ) 448 | 449 | if device_modification["entity_modification"] is not None: 450 | await self._async_apply_entity_modification( 451 | device, device_modification["entity_modification"] 452 | ) 453 | 454 | if device_modification["merge_modification"] is not None: 455 | await self._async_apply_merge_modification( 456 | device, device_modification["merge_modification"] 457 | ) 458 | 459 | if entry_id not in device.config_entries: 460 | self._device_registry.async_update_device( 461 | device.id, 462 | add_config_entry_id=entry_id, 463 | ) 464 | 465 | async def _async_apply_attribute_modification( 466 | self, device: DeviceEntry, attribute_modification: AttributeModification 467 | ) -> None: 468 | """Apply attribute modification to a device.""" 469 | 470 | manufacturer: str | None = attribute_modification.get("manufacturer") 471 | model: str | None = attribute_modification.get("model") 472 | sw_version: str | None = attribute_modification.get("sw_version") 473 | hw_version: str | None = attribute_modification.get("hw_version") 474 | serial_number: str | None = attribute_modification.get("serial_number") 475 | via_device_id: str | None = attribute_modification.get("via_device_id") 476 | 477 | self._device_registry.async_update_device( 478 | device.id, 479 | manufacturer=manufacturer, 480 | model=model, 481 | sw_version=sw_version, 482 | hw_version=hw_version, 483 | serial_number=serial_number, 484 | via_device_id=via_device_id, 485 | ) 486 | 487 | self._device_registry.async_update_device(device.id) 488 | 489 | async def _async_apply_entity_modification( 490 | self, device: DeviceEntry, entity_modification: EntityModification 491 | ) -> None: 492 | """Apply entity modification to a device.""" 493 | 494 | entities = [ 495 | self._entity_registry.async_get(entity_id) 496 | for entity_id in entity_modification["entities"] 497 | ] 498 | 499 | for entity in entities: 500 | if TYPE_CHECKING: 501 | assert entity is not None 502 | 503 | await self._async_save_original_entity_config(entity.id) 504 | 505 | if entity.device_id == device.id: 506 | continue 507 | 508 | self._entity_registry.async_update_entity( 509 | entity.entity_id, device_id=device.id 510 | ) 511 | 512 | async def _async_apply_merge_modification( 513 | self, device: DeviceEntry, merge_modification: MergeModification 514 | ) -> None: 515 | """Apply merge modification to a device.""" 516 | 517 | devices: dict[str, DeviceEntry | None] = { 518 | device_id: self._device_registry.async_get(device_id) 519 | for device_id in merge_modification["devices"] 520 | } 521 | 522 | for source_device_id, source_device in devices.items(): 523 | if source_device is None: 524 | self._logger.error( 525 | "[%s] Device not found (id: %s)", 526 | device.name, 527 | source_device_id, 528 | ) 529 | continue 530 | 531 | await self._async_save_original_device_config(source_device.id) 532 | 533 | entities = async_entries_for_device( 534 | self._entity_registry, 535 | source_device.id, 536 | include_disabled_entities=True, 537 | ) 538 | 539 | for entity in entities: 540 | await self._async_save_original_entity_config(entity.id) 541 | 542 | self._entity_registry.async_update_entity( 543 | entity.entity_id, device_id=device.id 544 | ) 545 | 546 | source_config_entries = source_device.config_entries 547 | 548 | for source_config_entry in source_config_entries: 549 | await self._async_add_config_entry(device.id, source_config_entry) 550 | -------------------------------------------------------------------------------- /custom_components/device_tools/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "device_tools", 3 | "name": "Device Tools", 4 | "codeowners": ["@EuleMitKeule"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/eulemitkeule/device-tools", 7 | "iot_class": "assumed_state", 8 | "issue_tracker": "https://github.com/eulemitkeule/device-tools/issues", 9 | "version": "0.0.0" 10 | } 11 | -------------------------------------------------------------------------------- /custom_components/device_tools/models.py: -------------------------------------------------------------------------------- 1 | """Models for device tools.""" 2 | 3 | from typing import TypedDict 4 | 5 | 6 | class AttributeModification(TypedDict): 7 | """Attribute modification data class.""" 8 | 9 | manufacturer: str | None 10 | model: str | None 11 | sw_version: str | None 12 | hw_version: str | None 13 | serial_number: str | None 14 | via_device_id: str | None 15 | 16 | 17 | class EntityModification(TypedDict): 18 | """Entity modification data class.""" 19 | 20 | entities: set[str] 21 | 22 | 23 | class MergeModification(TypedDict): 24 | """Merge modification data class.""" 25 | 26 | devices: set[str] 27 | 28 | 29 | class DeviceModification(TypedDict): 30 | """Device modification data class.""" 31 | 32 | modification_name: str 33 | device_id: str | None 34 | device_name: str 35 | attribute_modification: AttributeModification | None 36 | entity_modification: EntityModification | None 37 | merge_modification: MergeModification | None 38 | 39 | 40 | class OriginalEntityConfig(TypedDict): 41 | """Entity original config data class.""" 42 | 43 | device_id: str | None 44 | 45 | 46 | class OriginalDeviceConfig(TypedDict): 47 | """Device original config data class.""" 48 | 49 | manufacturer: str | None 50 | model: str | None 51 | sw_version: str | None 52 | hw_version: str | None 53 | serial_number: str | None 54 | via_device_id: str | None 55 | config_entries: set[str] 56 | config_entries_set_by_device_tools: set[str] 57 | 58 | 59 | class DeviceToolsConfigEntryData(TypedDict): 60 | """Device Tools config entry.""" 61 | 62 | device_modification: DeviceModification 63 | 64 | 65 | class DeviceToolsData(TypedDict): 66 | """Device Tools data class.""" 67 | 68 | original_entity_configs: dict[str, OriginalEntityConfig] 69 | original_device_configs: dict[str, OriginalDeviceConfig] 70 | -------------------------------------------------------------------------------- /custom_components/device_tools/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Das Gerät ist bereits konfiguriert.", 5 | "device_not_found": "Das Gerät konnte nicht gefunden werden.", 6 | "device_disabled": "Das Gerät ist deaktiviert." 7 | }, 8 | "flow_title": "Füge eine neue Modifikation hinzu", 9 | "step": { 10 | "user": { 11 | "title": "Füge eine neue Modifikation hinzu", 12 | "data": { 13 | "modification_name": "Name der Modifikation", 14 | "device_id": "Gerät (leer lassen um ein neues Gerät anzulegen)" 15 | } 16 | }, 17 | "device": { 18 | "title": "Neues Gerät erstellen", 19 | "data": { 20 | "device_name": "Gerätename" 21 | } 22 | } 23 | } 24 | }, 25 | "options": { 26 | "abort": { 27 | "device_not_found": "Das Gerät konnte nicht gefunden werden.", 28 | "device_disabled": "Das Gerät ist deaktiviert.", 29 | "device_already_merged": "Das Gerät wurde bereits zusammengeführt.", 30 | "device_in_use": "Das Gerät wird bereits von einer anderen Modifikation verwendet." 31 | }, 32 | "step": { 33 | "attributes": { 34 | "title": "Konfiguriere Geräteattribute", 35 | "data": { 36 | "manufacturer": "Hersteller", 37 | "model": "Modell", 38 | "via_device_id": "Übergeordnete Geräte-ID", 39 | "sw_version": "Softwareversion", 40 | "hw_version": "Hardwareversion", 41 | "serial_number": "Seriennummer" 42 | } 43 | }, 44 | "entities": { 45 | "title": "Konfiguriere Entitäten eines Gerätes", 46 | "data": { 47 | "entities": "Entitäten" 48 | } 49 | }, 50 | "merge": { 51 | "title": "Führe Geräte zusammen", 52 | "data": { 53 | "devices": "Geräte zum Zusammenführen" 54 | } 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /custom_components/device_tools/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "device_not_found": "Device not found", 6 | "device_disabled": "Device is disabled" 7 | }, 8 | "flow_title": "Add new modification", 9 | "step": { 10 | "user": { 11 | "title": "Add new modification", 12 | "data": { 13 | "modification_name": "Name of the modification", 14 | "device_id": "Device (leave empty to create new device)" 15 | } 16 | }, 17 | "device": { 18 | "title": "Create new device", 19 | "data": { 20 | "device_name": "Device Name" 21 | } 22 | } 23 | } 24 | }, 25 | "options": { 26 | "abort": { 27 | "device_not_found": "Device not found", 28 | "device_disabled": "Device is disabled", 29 | "device_already_merged": "Device is already merged", 30 | "device_in_use": "Device is in use by another modification" 31 | }, 32 | "step": { 33 | "attributes": { 34 | "title": "Configure device attributes", 35 | "data": { 36 | "manufacturer": "Manufacturer", 37 | "model": "Model", 38 | "via_device_id": "Via device ID", 39 | "sw_version": "Software version", 40 | "hw_version": "Hardware version", 41 | "serial_number": "Serial number" 42 | } 43 | }, 44 | "entities": { 45 | "title": "Configure device entities", 46 | "data": { 47 | "entities": "Entities" 48 | } 49 | }, 50 | "merge": { 51 | "title": "Merge devices", 52 | "data": { 53 | "devices": "Devices to merge" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Device Tools", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "device-tools" 3 | authors = [{ name = "Lennard Beers", email = "l.beers@outlook.de"}] 4 | maintainers = [{ name = "Lennard Beers", email = "l.beers@outlook.de"}] 5 | license = {file = "LICENSE.md"} 6 | keywords = [ 7 | "homeassistant", 8 | "custom-component", 9 | "device-tools", 10 | "device", 11 | "tools", 12 | "integration", 13 | "home-assistant", 14 | "home-assistant-custom-component", 15 | "home-assistant-integration", 16 | "home-assistant-integration", 17 | ] 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "Intended Audience :: Users", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | description = "A custom component for Home Assistant to create and modify devices." 26 | readme = "README.md" 27 | requires-python = ">=3.12" 28 | version = "0.0.0" 29 | dependencies = [ 30 | "homeassistant>=2024.12" 31 | ] 32 | 33 | [dependency-groups] 34 | dev = [ 35 | "ruff", 36 | "mypy", 37 | "pytest", 38 | "pytest-asyncio", 39 | "pytest-cov", 40 | "homeassistant-stubs", 41 | "voluptuous-stubs", 42 | ] 43 | 44 | [project.urls] 45 | Repository = "https://github.com/eulemitkeule/device-tools" 46 | Documentation = "https://github.com/eulemitkeule/device-tools/blob/master/README.md" 47 | Changelog = "https://github.com/EuleMitKeule/device-tools/releases" 48 | Issues = "https://github.com/eulemitkeule/device-tools/issues" 49 | 50 | [tool.mypy] 51 | check_untyped_defs = true 52 | strict = true 53 | 54 | [tool.pytest.ini_options] 55 | asyncio_default_fixture_loop_scope = "function" 56 | 57 | [tool.ruff.lint] 58 | select = ["D", "ARG"] 59 | 60 | [tool.ruff.lint.pydocstyle] 61 | convention = "google" 62 | 63 | [tool.ruff.lint.per-file-ignores] 64 | "!custom_components/device_tools/**.py" = ["D"] 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the device-tools integration.""" 2 | -------------------------------------------------------------------------------- /tests/test_device_tools.py: -------------------------------------------------------------------------------- 1 | """Tests for the device_tools module.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | from custom_components.device_tools.device_tools import DeviceTools 6 | 7 | 8 | def test_init() -> None: 9 | hass = MagicMock() 10 | logger = MagicMock() 11 | 12 | device_tools = DeviceTools(hass, logger) 13 | 14 | assert device_tools._hass == hass 15 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | """Dummy test file to check if the test suite is working.""" 2 | 3 | 4 | def test_dummy() -> None: 5 | assert True 6 | --------------------------------------------------------------------------------