Compare commits

..

45 Commits

Author SHA1 Message Date
Franck Nijhof fce17c8e6f Bump version to 2026.6.0b0 2026-05-27 16:07:37 +00:00
Franck Nijhof 51d1d4aa9e Update MDI icons from frontend for 2026.6.0 beta (#172366) 2026-05-27 18:04:08 +02:00
Alex Romanov 8184b93151 Add Tuya smart kettle select entities (#171897)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-27 17:32:01 +02:00
Bram Kragten 403cb85bc8 Bump frontend to 20260527.0 (#172355) 2026-05-27 17:16:46 +02:00
Erik Montnemery 4bf3a5b4bd Adjust behavior of numerical condition and trigger between and outside (#172335) 2026-05-27 17:03:58 +02:00
robotsnh 5a73d78c90 refactor(ads): refactor local CONF_OPTIONS constant in select.py (#171957) 2026-05-27 16:53:33 +02:00
Stefan Agner ebd9934213 Add repair to migrate away from multiprotocol/Multi-PAN (#168431)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2026-05-27 16:37:02 +02:00
Thomas D 73898c29e2 Fix weather lux unit in Qbus integration (#172326) 2026-05-27 16:29:39 +02:00
Jan-Philipp Benecke 3372bf45ec Allow counter entities as source in trend (#171132) 2026-05-27 15:24:19 +01:00
epenet 9744388a4e Fix duplicate hvac_modes in Tuya climate (#172352) 2026-05-27 16:23:24 +02:00
Petro31 75c52a382e Add missing template entity device_tracker translation (#172346) 2026-05-27 16:21:50 +02:00
Erik Montnemery f8a65a7c6f Rename trigger behavior options (#172348) 2026-05-27 16:01:11 +02:00
Matt b2d934fae1 Fix dead code and redundant assignment in isy994 integration (#171904)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
2026-05-27 15:56:32 +02:00
Wendelin eb72a72182 Rename automation comments to note (#172312) 2026-05-27 15:23:06 +02:00
Abílio Costa a4b9de867c Add instruction about hardcoded entity ids in tests (#172341) 2026-05-27 14:18:31 +01:00
Erik Montnemery 3a4e697414 Add entity option to associate scanner tracker with any zone (#172157) 2026-05-27 15:17:30 +02:00
epenet 00010a7508 Bump tuya-device-handlers to 0.0.21 (#172315) 2026-05-27 14:52:15 +02:00
epenet c5e4e97fa9 Ignore quirks in Tuya snapshot tests (#172329) 2026-05-27 14:22:59 +02:00
renovate[bot] 3f6e323b48 Update ruff (#172343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 13:59:20 +02:00
renovate[bot] b9639ec9f6 Update uv to 0.11.16 (#172344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 13:59:05 +02:00
dependabot[bot] 31bce13d16 Bump actions/stale from 10.2.0 to 10.3.0 (#172319)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 13:28:44 +02:00
Petro31 3523a26abd Add template device_tracker platform (#171732) 2026-05-27 13:27:07 +02:00
Allen Porter a6fcc9f3ff Prefer external URL in WWW-Authenticate header for RFC 9728 (#169658) 2026-05-27 12:57:02 +02:00
cdnninja efe0000fbe Bump pyvesync to 3.4.2 (#168402) 2026-05-27 12:43:01 +02:00
starkillerOG 98a7cc66ef Reolink battery fast start (#171840) 2026-05-27 12:41:32 +02:00
Erik Montnemery 7feaf71b9e Make TrackerEntity in_zones win over lat/long (#172313) 2026-05-27 11:27:34 +02:00
Erik Montnemery 00a0fae7bc Improve numerical trigger and condition tests (#172308) 2026-05-27 11:23:49 +02:00
Bram Kragten 0c816c22e0 Remove show_advanced_options from data entry flow API (#172249) 2026-05-27 11:13:24 +02:00
epenet 42f277716d Ensure local_strategy is defined in tuya tests (#172328) 2026-05-27 10:52:14 +02:00
Ronald van der Meer 6669b0de25 Use Duco state codes for ventilation state labels (#172314) 2026-05-27 10:43:46 +02:00
wollew 50fca42624 Bump pyvlx to 0.2.35 (#172320) 2026-05-27 10:38:55 +02:00
Erik Montnemery deecb4ee9c Improve cast option flow tests (#172323) 2026-05-27 10:37:50 +02:00
Erik Montnemery 762f07f450 Add device_tracker platform to kitchen_sink (#172250) 2026-05-27 10:21:09 +02:00
Kevin McCormack e02ea041b7 Add config flow for OPNsense (#151121)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Philippe Lafoucrière <12752+gravis@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-05-27 10:15:16 +02:00
Petro31 7912afb765 Create issue when legacy platform setup is not supported for device_trackers (#172281) 2026-05-27 09:08:20 +02:00
Jan Bouwhuis 7adaa09333 Add override decorator for incomfort to comply with PEP 698 (#172244) 2026-05-27 08:20:16 +02:00
tronikos c5e7ed9aba Update recommended chat model to gemini-3.1-flash-lite (#172299) 2026-05-27 08:19:01 +02:00
Max Michels 68b8667998 Add missing exception translation key in aws_s3 (#172270) 2026-05-27 07:31:58 +02:00
J. Nick Koston f643dd98e5 Bump habluetooth to 6.7.9 (#172303) 2026-05-26 23:55:04 -05:00
J. Nick Koston dcec29dbbf Bump qingping-ble to 1.1.5 (#172305) 2026-05-26 22:41:55 -05:00
J. Nick Koston 1daff77591 Skip Linux only bluetooth scanner tests on non Linux platforms (#172304) 2026-05-26 22:41:41 -05:00
Yardian Support 7e3fc18c8c Update Yardian codeowners to @aeon-matrix (#172273) 2026-05-26 19:04:47 -05:00
J. Nick Koston b6cc5499aa Bump dbus-fast to 5.0.15 (#172298) 2026-05-26 19:00:28 -05:00
Manu 11920b82fe Fix typo in System Bridge (#172294)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 01:58:34 +02:00
tronikos 2649504dfb Fix hardcoded exception string in opower (#172295) 2026-05-27 01:29:31 +02:00
241 changed files with 6358 additions and 1449 deletions
@@ -1,42 +0,0 @@
name: Set up uv and managed Python
description: >-
Pins uv (avoids the raw.githubusercontent.com manifest fetch on cache miss)
and proactively installs the requested Python so cached venvs created with
`uv venv` resolve their interpreter symlinks in jobs that only restore the
venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the
interpreter until uv first uses it, so jobs that just activate the venv
blow up with broken symlinks on cache hit.
inputs:
python-version:
description: The Python version uv should install and use.
required: true
uv-version:
description: The uv version setup-uv should install.
required: true
outputs:
python-version:
description: The Python version uv reports as installed.
value: ${{ steps.uv.outputs.python-version }}
runs:
using: composite
steps:
- name: Set up uv
id: uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
# Persist astral's managed Python across jobs so 'uv venv' below is
# fast on the second job onwards.
cache-python: true
# Lint-only and codegen jobs touch no Python deps, so the post-step
# cache save would otherwise abort the job.
ignore-nothing-to-cache: true
- name: Install Python interpreter
shell: bash
env:
PYTHON_VERSION: ${{ inputs.python-version }}
run: uv python install "${PYTHON_VERSION}"
+1
View File
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching. - Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly. - If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself. - We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices ## Good practices
+47 -45
View File
@@ -37,7 +37,7 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 4 CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.6" HA_SHORT_VERSION: "2026.6"
ADDITIONAL_PYTHON_VERSIONS: "[]" ADDITIONAL_PYTHON_VERSIONS: "[]"
@@ -89,8 +89,6 @@ jobs:
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }} mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
postgresql_groups: ${{ steps.info.outputs.postgresql_groups }} postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
python_versions: ${{ steps.info.outputs.python_versions }} python_versions: ${{ steps.info.outputs.python_versions }}
default_python: ${{ steps.info.outputs.default_python }}
uv_version: ${{ steps.info.outputs.uv_version }}
test_full_suite: ${{ steps.info.outputs.test_full_suite }} test_full_suite: ${{ steps.info.outputs.test_full_suite }}
test_group_count: ${{ steps.info.outputs.test_group_count }} test_group_count: ${{ steps.info.outputs.test_group_count }}
test_groups: ${{ steps.info.outputs.test_groups }} test_groups: ${{ steps.info.outputs.test_groups }}
@@ -237,11 +235,6 @@ jobs:
echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
echo "python_versions: ${all_python_versions}" echo "python_versions: ${all_python_versions}"
echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
echo "default_python: ${default_python}"
echo "default_python=${default_python}" >> $GITHUB_OUTPUT
uv_version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)
echo "uv_version: ${uv_version}"
echo "uv_version=${uv_version}" >> $GITHUB_OUTPUT
echo "test_full_suite: ${test_full_suite}" echo "test_full_suite: ${test_full_suite}"
echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
echo "integrations_glob: ${integrations_glob}" echo "integrations_glob: ${integrations_glob}"
@@ -351,12 +344,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Set up uv and Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -404,13 +397,21 @@ jobs:
libudev-dev libudev-dev
version: ${{ env.APT_CACHE_VERSION }} version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true execute_install_scripts: true
- name: Read uv version from requirements.txt
if: steps.cache-venv.outputs.cache-hit != 'true'
id: read-uv-version
run: |
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
id: create-venv id: create-venv
env:
PYTHON_VERSION: ${{ steps.python.outputs.python-version }}
run: | run: |
uv venv venv --python "${PYTHON_VERSION}" python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version python --version
uv pip install -r requirements.txt uv pip install -r requirements.txt
@@ -418,6 +419,7 @@ jobs:
uv pip install -e . --config-settings editable_mode=compat uv pip install -e . --config-settings editable_mode=compat
- name: Dump pip freeze - name: Dump pip freeze
run: | run: |
python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version python --version
uv pip freeze >> pip_freeze.txt uv pip freeze >> pip_freeze.txt
@@ -478,10 +480,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }} version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -515,10 +517,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -551,10 +553,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Run gen_copilot_instructions.py - name: Run gen_copilot_instructions.py
run: | run: |
python -m script.gen_copilot_instructions validate python -m script.gen_copilot_instructions validate
@@ -606,10 +608,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -657,10 +659,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -710,10 +712,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -761,10 +763,10 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Generate partial mypy restore key - name: Generate partial mypy restore key
id: generate-mypy-key id: generate-mypy-key
run: | run: |
@@ -838,10 +840,10 @@ jobs:
execute_install_scripts: true execute_install_scripts: true
- name: Set up Python - name: Set up Python
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }} python-version-file: ".python-version"
python-version: ${{ needs.info.outputs.default_python }} check-latest: true
- name: Restore full Python virtual environment - name: Restore full Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -903,10 +905,10 @@ jobs:
execute_install_scripts: true execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1045,10 +1047,10 @@ jobs:
execute_install_scripts: true execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1201,10 +1203,10 @@ jobs:
version: ${{ env.APT_CACHE_VERSION }} version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -1369,10 +1371,10 @@ jobs:
execute_install_scripts: true execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: ./.github/actions/setup-uv-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 60
@@ -67,7 +67,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -97,7 +97,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"
+1 -1
View File
@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13 rev: v0.15.14
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
+1
View File
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching. - Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly. - If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself. - We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices ## Good practices
Generated
+2 -2
View File
@@ -2054,8 +2054,8 @@ CLAUDE.md @home-assistant/core
/tests/components/yamaha_musiccast/ @vigonotion @micha91 /tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis /homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @h3l1o5 /homeassistant/components/yardian/ @aeon-matrix
/tests/components/yardian/ @h3l1o5 /tests/components/yardian/ @aeon-matrix
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward /homeassistant/components/yeelightsunflower/ @lindsaymarkward
+1 -4
View File
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA, PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
SelectEntity, SelectEntity,
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME, CONF_OPTIONS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -19,9 +19,6 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select" DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
@@ -1,7 +1,7 @@
.trigger_common_fields: .trigger_common_fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -5,7 +5,7 @@
fields: &trigger_common_fields fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
translation_key="invalid_bucket_name", translation_key="invalid_bucket_name",
) from err ) from err
except ValueError as err: except ValueError as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError( raise ConfigEntryError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_endpoint_url", translation_key="invalid_endpoint_url",
+4 -1
View File
@@ -7,7 +7,7 @@
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]", "cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]", "invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]", "invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL." "invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
}, },
"step": { "step": {
"user": { "user": {
@@ -48,6 +48,9 @@
}, },
"invalid_credentials": { "invalid_credentials": {
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key." "message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
},
"invalid_endpoint_url": {
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
} }
} }
} }
@@ -1,7 +1,7 @@
.trigger_common_fields: .trigger_common_fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0", "bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4", "bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18", "bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.14", "dbus-fast==5.0.15",
"habluetooth==6.7.4" "habluetooth==6.7.9"
] ]
} }
+2 -2
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import ( from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityNumericalStateChangedTriggerBase, EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase, EntityNumericalStateCrossedThresholdTriggerBase,
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode" CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{ {
vol.Required(CONF_OPTIONS): { vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All( vol.Required(CONF_HVAC_MODE): vol.All(
@@ -5,7 +5,7 @@
fields: fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
vol.Schema( vol.Schema(
{ {
vol.Required("handler"): vol.Any(str, list), vol.Required("handler"): vol.Any(str, list),
vol.Optional("show_advanced_options", default=False): cv.boolean,
vol.Optional("entry_id"): cv.string, vol.Optional("entry_id"): cv.string,
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
vol.Schema( vol.Schema(
{ {
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)), vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
vol.Optional("show_advanced_options", default=False): cv.boolean,
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME, ATTR_LOCATION_NAME,
ATTR_MAC, ATTR_MAC,
ATTR_SOURCE_TYPE, ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME, CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS, CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults" CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
ATTR_ATTRIBUTES: Final = "attributes" ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery" ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id" ATTR_DEV_ID: Final = "dev_id"
+144 -14
View File
@@ -1,7 +1,7 @@
"""Provide functionality to keep track of devices.""" """Provide functionality to keep track of devices."""
import asyncio import asyncio
from typing import Any, final from typing import TYPE_CHECKING, Any, final
from propcache.api import cached_property from propcache.api import cached_property
@@ -16,8 +16,19 @@ from homeassistant.const import (
STATE_NOT_HOME, STATE_NOT_HOME,
EntityCategory, EntityCategory,
) )
from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.core import (
from homeassistant.helpers import device_registry as dr, entity_registry as er CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import (
DeviceInfo, DeviceInfo,
EventDeviceRegistryUpdatedData, EventDeviceRegistryUpdatedData,
@@ -25,6 +36,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .const import ( from .const import (
@@ -33,6 +45,7 @@ from .const import (
ATTR_IP, ATTR_IP,
ATTR_MAC, ATTR_MAC,
ATTR_SOURCE_TYPE, ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED, CONNECTED_DEVICE_REGISTERED,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
@@ -221,8 +234,8 @@ class TrackerEntity(
"""Return the entity_id of zones the device is currently in. """Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and and discards zones which do not exist. Takes precedence over latitude
longitude are both set. and longitude when set (including when set to an empty list).
""" """
return self._attr_in_zones return self._attr_in_zones
@@ -252,11 +265,7 @@ class TrackerEntity(
@callback @callback
def _async_write_ha_state(self) -> None: def _async_write_ha_state(self) -> None:
"""Calculate active zones.""" """Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None: if (zones := self.in_zones) is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted( zone_states = sorted(
( (
zone_state zone_state
@@ -270,6 +279,12 @@ class TrackerEntity(
None, None,
) )
self.__in_zones = [z.entity_id for z in zone_states] self.__in_zones = [z.entity_id for z in zone_states]
elif (
self.available and self.latitude is not None and self.longitude is not None
):
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else: else:
self.__active_zone = None self.__active_zone = None
self.__in_zones = None self.__in_zones = None
@@ -317,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device. addresses being used to identify the device.
""" """
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
await super().async_internal_will_remove_from_hass()
if not self.registry_entry:
return
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.
Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME
if new_zone == self._scanner_option_associated_zone:
return
# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
self._scanner_option_associated_zone = new_zone
# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()
@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()
@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)
@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
if TYPE_CHECKING:
assert self.registry_entry
return f"associated_zone_missing_{self.registry_entry.id}"
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return the state of the device.""" """Return the state of the device."""
if self.is_connected is None: if self.is_connected is None:
return None return None
if self.is_connected: if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
return STATE_HOME return STATE_HOME
return STATE_NOT_HOME if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None
@property @property
def is_connected(self) -> bool | None: def is_connected(self) -> bool | None:
@@ -341,9 +462,18 @@ class BaseScannerEntity(BaseTrackerEntity):
if not self.is_connected: if not self.is_connected:
return attr return attr
associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr
attr[ATTR_IN_ZONES] = [ attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME, associated_zone,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME), *zone.async_get_enclosing_zones(self.hass, associated_zone),
] ]
return attr return attr
@@ -38,6 +38,9 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
async_create_platform_config_not_supported_issue,
)
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_time_interval, async_track_time_interval,
async_track_utc_time_change, async_track_utc_time_change,
@@ -379,8 +382,8 @@ async def async_extract_config(
if platform.type == PLATFORM_TYPE_LEGACY: if platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform) legacy.append(platform)
else: else:
raise ValueError( async_create_platform_config_not_supported_issue(
f"Unable to determine type for {platform.name}: {platform.type}" hass, platform.name, DOMAIN
) )
return legacy return legacy
@@ -44,6 +44,12 @@
} }
} }
}, },
"issues": {
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
}
},
"services": { "services": {
"see": { "see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.", "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+17 -17
View File
@@ -64,23 +64,23 @@
"ventilation_state": { "ventilation_state": {
"name": "Ventilation state", "name": "Ventilation state",
"state": { "state": {
"aut1": "Automatic boost (15 min)", "aut1": "AUT1",
"aut2": "Automatic boost (30 min)", "aut2": "AUT2",
"aut3": "Automatic boost (45 min)", "aut3": "AUT3",
"auto": "Automatic", "auto": "AUTO",
"cnt1": "Continuous low speed", "cnt1": "CNT1",
"cnt2": "Continuous medium speed", "cnt2": "CNT2",
"cnt3": "Continuous high speed", "cnt3": "CNT3",
"empt": "Empty house", "empt": "EMPT",
"man1": "Manual low speed (15 min)", "man1": "MAN1",
"man1x2": "Manual low speed (30 min)", "man1x2": "MAN1x2",
"man1x3": "Manual low speed (45 min)", "man1x3": "MAN1x3",
"man2": "Manual medium speed (15 min)", "man2": "MAN2",
"man2x2": "Manual medium speed (30 min)", "man2x2": "MAN2x2",
"man2x3": "Manual medium speed (45 min)", "man2x3": "MAN2x3",
"man3": "Manual high speed (15 min)", "man3": "MAN3",
"man3x2": "Manual high speed (30 min)", "man3x2": "MAN3x2",
"man3x3": "Manual high speed (45 min)" "man3x3": "MAN3x3"
} }
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -21,5 +21,5 @@
"integration_type": "system", "integration_type": "system",
"preview_features": { "winter_mode": {} }, "preview_features": { "winter_mode": {} },
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.4"] "requirements": ["home-assistant-frontend==20260527.0"]
} }
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image" RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
@@ -3,7 +3,7 @@
"name": "Home Assistant Hardware", "name": "Home Assistant Hardware",
"after_dependencies": ["hassio"], "after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["usb"], "dependencies": ["repairs", "usb"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system", "integration_type": "system",
"requirements": [ "requirements": [
@@ -0,0 +1,72 @@
"""Repairs for the Home Assistant Hardware integration."""
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration"
@callback
def _multi_pan_issue_id(config_entry: ConfigEntry) -> str:
"""Return the issue id for the multi-PAN migration issue of an entry."""
return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}"
@callback
def async_create_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Create a repair issue to guide migration away from Multi-PAN."""
ir.async_create_issue(
hass,
domain=domain,
issue_id=_multi_pan_issue_id(config_entry),
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_MULTI_PAN_MIGRATION,
translation_placeholders={"hardware_name": config_entry.title},
data={"entry_id": config_entry.entry_id},
)
@callback
def async_delete_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Delete the multi-PAN migration repair issue for this entry."""
ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry))
class MultiPanMigrationRepairFlow(RepairsFlow):
"""Reuse the multi-PAN options flow uninstall steps as a repair flow.
Subclass this together with the hardware-specific
``MultiPanOptionsFlowHandler`` in each hardware integration's repairs
module.
The repair flow runs in the repairs flow manager where ``self.handler``
is the integration domain rather than the hardware config entry id, so
the ``config_entry`` accessor of ``OptionsFlow`` must be overridden.
"""
_repair_config_entry: ConfigEntry
@property
def config_entry(self) -> ConfigEntry:
"""Return the hardware config entry to migrate."""
return self._repair_config_entry
async def _async_step_start_migration(self) -> RepairsFlowResult:
"""Jump straight into the uninstall step of the migration flow.
The repair flow's init data is the issue context, not user form input,
so pass None to render the uninstall confirmation form.
"""
return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return]
@@ -6,6 +6,8 @@ import dataclasses
import logging import logging
from typing import Any, Protocol from typing import Any, Protocol
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
import voluptuous as vol import voluptuous as vol
import yarl import yarl
@@ -25,6 +27,7 @@ from homeassistant.config_entries import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.integration_platform import (
async_process_integration_platforms, async_process_integration_platforms,
@@ -37,15 +40,18 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.singleton import singleton from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
from .util import (
ApplicationType,
WaitingAddonManager,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager" DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
CONF_ADDON_DEVICE = "device" CONF_ADDON_DEVICE = "device"
@@ -71,53 +77,6 @@ async def get_multiprotocol_addon_manager(
return manager return manager
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state is AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class MultiprotocolAddonManager(WaitingAddonManager): class MultiprotocolAddonManager(WaitingAddonManager):
"""Silicon Labs Multiprotocol add-on manager.""" """Silicon Labs Multiprotocol add-on manager."""
@@ -265,18 +224,6 @@ class MultipanProtocol(Protocol):
""" """
@singleton(DATA_FLASHER_ADDON_MANAGER)
@callback
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
"""Get the flasher add-on manager."""
return WaitingAddonManager(
hass,
LOGGER,
"Silicon Labs Flasher",
SILABS_FLASHER_ADDON_SLUG,
)
@dataclasses.dataclass @dataclasses.dataclass
class SerialPortSettings: class SerialPortSettings:
"""Serial port settings.""" """Serial port settings."""
@@ -339,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
def _zha_name(self) -> str: def _zha_name(self) -> str:
"""Return the ZHA name.""" """Return the ZHA name."""
@abstractmethod
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
@abstractmethod
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp')."""
@property
@abstractmethod
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
@property @property
def flow_manager(self) -> OptionsFlowManager: def flow_manager(self) -> OptionsFlowManager:
"""Return the correct flow manager.""" """Return the correct flow manager."""
@@ -686,61 +646,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
async def async_step_firmware_revert( async def async_step_firmware_revert(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Install the flasher addon, if necessary.""" """Initiate ZHA backup and start multiprotocol addon uninstall."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
if addon_info.state is AddonState.NOT_INSTALLED:
return await self.async_step_install_flasher_addon()
if addon_info.state is AddonState.NOT_RUNNING:
return await self.async_step_configure_flasher_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="addon_already_running",
description_placeholders={"addon_name": flasher_manager.addon_name},
)
async def async_step_install_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing flasher addon."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
_LOGGER.debug("Flasher addon state: %s", addon_info)
if not self.install_task:
self.install_task = self.hass.async_create_task(
flasher_manager.async_install_addon_waiting(),
"SiLabs Flasher addon install",
eager_start=False,
)
if not self.install_task.done():
return self.async_show_progress(
step_id="install_flasher_addon",
progress_action="install_addon",
description_placeholders={"addon_name": flasher_manager.addon_name},
progress_task=self.install_task,
)
try:
await self.install_task
except AddonError as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="install_failed")
finally:
self.install_task = None
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
async def async_step_configure_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Perform initial backup and reconfigure ZHA."""
# pylint: disable=home-assistant-component-root-import # pylint: disable=home-assistant-component-root-import
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
@@ -782,17 +688,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
_LOGGER.exception("Unexpected exception during ZHA migration") _LOGGER.exception("Unexpected exception during ZHA migration")
raise AbortFlow("zha_migration_failed") from err raise AbortFlow("zha_migration_failed") from err
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
new_addon_config = {
**addon_info.options,
"device": new_settings.device,
"flow_control": new_settings.flow_control,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, flasher_manager)
return await self.async_step_uninstall_multiprotocol_addon() return await self.async_step_uninstall_multiprotocol_addon()
async def async_step_uninstall_multiprotocol_addon( async def async_step_uninstall_multiprotocol_addon(
@@ -821,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC):
finally: finally:
self.stop_task = None self.stop_task = None
return self.async_show_progress_done(next_step_id="start_flasher_addon") return self.async_show_progress_done(next_step_id="install_zigbee_firmware")
async def async_step_start_flasher_addon( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Start Silicon Labs Flasher add-on.""" """Flash Zigbee firmware directly onto the radio."""
flasher_manager = get_flasher_addon_manager(self.hass) if not self.install_task:
if not self.start_task: async def _flash_firmware() -> None:
serial_port_settings = await self._async_serial_port_settings()
device = serial_port_settings.device
async def start_and_wait_until_done() -> None: # For the duration of firmware flashing, hint to other integrations
await flasher_manager.async_start_addon_waiting() # (i.e. ZHA) that the hardware is in use and should not be accessed.
# Now that the addon is running, wait for it to finish async with async_firmware_flashing_context(self.hass, device, DOMAIN):
await flasher_manager.async_wait_until_addon_state( session = async_get_clientsession(self.hass)
AddonState.NOT_RUNNING client = FirmwareUpdateClient(self._firmware_update_url(), session)
)
self.start_task = self.hass.async_create_task( try:
start_and_wait_until_done(), eager_start=False manifest = await client.async_update_data()
fw_manifest = next(
fw
for fw in manifest.firmwares
if fw.filename.startswith(self._zigbee_firmware_type())
)
fw_data = await client.async_fetch_firmware(fw_manifest)
except (
StopIteration,
TimeoutError,
ClientError,
ManifestMissing,
ValueError,
) as err:
raise HomeAssistantError(
"Failed to fetch Zigbee firmware"
) from err
await async_flash_silabs_firmware(
hass=self.hass,
device=device,
fw_data=fw_data,
flasher_cls=self._flasher_cls,
expected_installed_firmware_type=ApplicationType.EZSP,
progress_callback=lambda offset, total: (
self.async_update_progress(offset / total)
),
)
self.install_task = self.hass.async_create_task(
_flash_firmware(),
"Flash Zigbee firmware",
eager_start=False,
) )
if not self.start_task.done(): if not self.install_task.done():
return self.async_show_progress( return self.async_show_progress(
step_id="start_flasher_addon", step_id="install_zigbee_firmware",
progress_action="start_flasher_addon", progress_action="install_zigbee_firmware",
description_placeholders={"addon_name": flasher_manager.addon_name}, description_placeholders={
progress_task=self.start_task, "hardware_name": self._hardware_name(),
},
progress_task=self.install_task,
) )
try: try:
await self.start_task await self.install_task
except (AddonError, AbortFlow) as err: except HomeAssistantError as err:
_LOGGER.error(err) _LOGGER.error("Failed to flash Zigbee firmware: %s", err)
return self.async_show_progress_done(next_step_id="flasher_failed") return self.async_show_progress_done(next_step_id="firmware_flash_failed")
finally: finally:
self.start_task = None self.install_task = None
return self.async_show_progress_done(next_step_id="flashing_complete") return self.async_show_progress_done(next_step_id="flashing_complete")
async def async_step_flasher_failed( async def async_step_firmware_flash_failed(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Flasher add-on start failed.""" """Firmware flashing failed."""
flasher_manager = get_flasher_addon_manager(self.hass)
return self.async_abort( return self.async_abort(
reason="addon_start_failed", reason="fw_install_failed",
description_placeholders={"addon_name": flasher_manager.addon_name}, description_placeholders={"firmware_name": "Zigbee"},
) )
async def async_step_flashing_complete( async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Finish flashing and update the config entry.""" """Finish flashing and update the config entry."""
flasher_manager = get_flasher_addon_manager(self.hass)
await flasher_manager.async_uninstall_addon_waiting()
# Finish ZHA migration if needed # Finish ZHA migration if needed
if self._zha_migration_mgr: if self._zha_migration_mgr:
try: try:
@@ -102,7 +102,9 @@
}, },
"progress": { "progress": {
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.", "install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds." "install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.",
"uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled."
}, },
"step": { "step": {
"addon_installed_other_device": { "addon_installed_other_device": {
@@ -37,13 +37,59 @@ from .const import (
ZIGBEE_FLASHER_ADDON_SLUG, ZIGBEE_FLASHER_ADDON_SLUG,
) )
from .helpers import async_firmware_update_context from .helpers import async_firmware_update_context
from .silabs_multiprotocol_addon import (
WaitingAddonManager,
get_multiprotocol_addon_manager,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state == AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class ApplicationType(StrEnum): class ApplicationType(StrEnum):
"""Application type running on a device.""" """Application type running on a device."""
@@ -279,6 +325,11 @@ async def guess_hardware_owners(
assert otbr_addon_fw_info is not None assert otbr_addon_fw_info is not None
device_guesses[otbr_path].append(otbr_addon_fw_info) device_guesses[otbr_path].append(otbr_addon_fw_info)
# Lazy import to avoid circular dependency
from .silabs_multiprotocol_addon import ( # noqa: PLC0415
get_multiprotocol_addon_manager,
)
multipan_addon_manager = await get_multiprotocol_addon_manager(hass) multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
try: try:
@@ -7,6 +7,13 @@ import os.path
from homeassistant.components.homeassistant_hardware.coordinator import ( from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator, FirmwareUpdateCoordinator,
) )
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
from homeassistant.components.usb import ( from homeassistant.components.usb import (
USBDevice, USBDevice,
@@ -92,6 +99,16 @@ async def async_setup_entry(
translation_key="device_disconnected", translation_key="device_disconnected",
) )
try:
uses_multi_pan = await multi_pan_addon_using_device(hass, device_path)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if uses_multi_pan:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
# Create and store the firmware update coordinator in runtime_data # Create and store the firmware update coordinator in runtime_data
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
coordinator = FirmwareUpdateCoordinator( coordinator = FirmwareUpdateCoordinator(
@@ -248,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
"""Return the name of the hardware.""" """Return the name of the hardware."""
return self._hw_variant.full_name return self._hw_variant.full_name
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "skyconnect_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return Zbt1Flasher # type: ignore[no-any-return]
async def async_step_flashing_complete( async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -0,0 +1,48 @@
"""Repairs for the Home Assistant SkyConnect integration."""
from typing import Any, cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler
class SkyConnectMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant SkyConnect."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry)
self._repair_config_entry = config_entry
async def async_step_init( # type: ignore[override]
self, user_input: dict[str, Any] | None = None
) -> RepairsFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a SkyConnect repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return SkyConnectMultiPanMigrationRepairFlow(entry)
return ConfirmRepairFlow()
@@ -106,6 +106,37 @@
"message": "The device is not plugged in" "message": "The device is not plugged in"
} }
}, },
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": { "options": {
"abort": { "abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -130,8 +161,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
}, },
"step": { "step": {
"addon_installed_other_device": { "addon_installed_other_device": {
@@ -7,8 +7,13 @@ from homeassistant.components.hassio import HassioNotReadyError, get_os_info
from homeassistant.components.homeassistant_hardware.coordinator import ( from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator, FirmwareUpdateCoordinator,
) )
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
check_multi_pan_addon, check_multi_pan_addon,
multi_pan_addon_using_device,
) )
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
@@ -27,6 +32,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from .const import ( from .const import (
DOMAIN,
FIRMWARE, FIRMWARE,
FIRMWARE_VERSION, FIRMWARE_VERSION,
MANUFACTURER, MANUFACTURER,
@@ -77,6 +83,16 @@ async def async_setup_entry(
except HomeAssistantError as err: except HomeAssistantError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
try:
multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if multipan_using_device:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
if firmware is ApplicationType.EZSP: if firmware is ApplicationType.EZSP:
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
hass, hass,
@@ -319,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
"""Return the name of the hardware.""" """Return the name of the hardware."""
return BOARD_NAME return BOARD_NAME
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "yellow_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return YellowFlasher # type: ignore[no-any-return]
async def async_step_flashing_complete( async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -0,0 +1,48 @@
"""Repairs for the Home Assistant Yellow integration."""
from typing import cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler
class YellowMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant Yellow."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry)
self._repair_config_entry = config_entry
async def async_step_main_menu( # type: ignore[override]
self, _: None = None
) -> RepairsFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a Yellow repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return YellowMultiPanMigrationRepairFlow(hass, entry)
return ConfirmRepairFlow()
@@ -11,6 +11,37 @@
} }
} }
}, },
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": { "options": {
"abort": { "abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -37,8 +68,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
}, },
"step": { "step": {
"addon_installed_other_device": { "addon_installed_other_device": {
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import ( from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTargetStateTriggerBase, EntityTargetStateTriggerBase,
Trigger, Trigger,
TriggerConfig, TriggerConfig,
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{ {
vol.Required(CONF_OPTIONS): { vol.Required(CONF_OPTIONS): {
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
@@ -5,7 +5,7 @@
fields: fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: .trigger_common_fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
-1
View File
@@ -15,7 +15,6 @@ CONF_INFO = "info"
CONF_INVERTING = "inverting" CONF_INVERTING = "inverting"
CONF_LIGHT = "light" CONF_LIGHT = "light"
CONF_NODE = "node" CONF_NODE = "node"
CONF_NOTE = "note"
CONF_OFF_ID = "off_id" CONF_OFF_ID = "off_id"
CONF_ON_ID = "on_id" CONF_ON_ID = "on_id"
CONF_POSITION = "position" CONF_POSITION = "position"
+1 -1
View File
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
from homeassistant.const import ( from homeassistant.const import (
CONF_ID, CONF_ID,
CONF_NAME, CONF_NAME,
CONF_NOTE,
CONF_PASSWORD, CONF_PASSWORD,
CONF_TYPE, CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
@@ -25,7 +26,6 @@ from .const import (
CONF_INFO, CONF_INFO,
CONF_INVERTING, CONF_INVERTING,
CONF_LIGHT, CONF_LIGHT,
CONF_NOTE,
CONF_OFF_ID, CONF_OFF_ID,
CONF_ON_ID, CONF_ON_ID,
CONF_POSITION, CONF_POSITION,
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -2,7 +2,7 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, override
from incomfortclient import Heater as InComfortHeater from incomfortclient import Heater as InComfortHeater
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}" self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property @property
@override
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return the status of the sensor.""" """Return the status of the sensor."""
return bool(self._heater.status[self.entity_description.value_key]) return bool(self._heater.status[self.entity_description.value_key])
@property @property
@override
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes.""" """Return the device state attributes."""
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None: if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
@@ -1,6 +1,6 @@
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" """Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
from typing import Any from typing import Any, override
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
) )
@property @property
@override
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes.""" """Return the device state attributes."""
return {"status": self._room.status} return {"status": self._room.status}
@property @property
@override
def current_temperature(self) -> float | None: def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
return self._room.room_temp return self._room.room_temp
@property @property
@override
def hvac_action(self) -> HVACAction | None: def hvac_action(self) -> HVACAction | None:
"""Return the actual current HVAC action.""" """Return the actual current HVAC action."""
if self._heater.is_burning and self._heater.is_pumping: if self._heater.is_burning and self._heater.is_pumping:
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return HVACAction.IDLE return HVACAction.IDLE
@property @property
@override
def target_temperature(self) -> float | None: def target_temperature(self) -> float | None:
"""Return the (override)temperature we try to reach. """Return the (override)temperature we try to reach.
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
return self._room.setpoint return self._room.setpoint
return self._room.override or self._room.setpoint return self._room.override or self._room.setpoint
@override
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature for this zone.""" """Set a new target temperature for this zone."""
temperature: float = kwargs[ATTR_TEMPERATURE] temperature: float = kwargs[ATTR_TEMPERATURE]
await self._room.set_override(temperature) await self._room.set_override(temperature)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode.""" """Set new target hvac mode."""
@@ -2,7 +2,7 @@
from collections.abc import Mapping from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any, override
from incomfortclient import InvalidGateway, InvalidHeaterList from incomfortclient import InvalidGateway, InvalidHeaterList
import voluptuous as vol import voluptuous as vol
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_host: str _discovered_host: str
@override
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return InComfortOptionsFlowHandler() return InComfortOptionsFlowHandler()
@override
async def async_step_dhcp( async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={CONF_HOST: self._discovered_host}, description_placeholders={CONF_HOST: self._discovered_host},
) )
@override
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -3,7 +3,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any, override
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from incomfortclient import ( from incomfortclient import (
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
) )
self.incomfort_data = incomfort_data self.incomfort_data = incomfort_data
@override
async def _async_update_data(self) -> InComfortData: async def _async_update_data(self) -> InComfortData:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""
try: try:
+3 -1
View File
@@ -1,7 +1,7 @@
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, override
from incomfortclient import Heater as InComfortHeater from incomfortclient import Heater as InComfortHeater
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
self._attr_unique_id = f"{heater.serial_no}_{description.key}" self._attr_unique_id = f"{heater.serial_no}_{description.key}"
@property @property
@override
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return] return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
@property @property
@override
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes.""" """Return the device state attributes."""
if (extra_key := self.entity_description.extra_key) is None: if (extra_key := self.entity_description.extra_key) is None:
@@ -1,7 +1,7 @@
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
import logging import logging
from typing import Any from typing import Any, override
from incomfortclient import Heater as InComfortHeater from incomfortclient import Heater as InComfortHeater
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
self._attr_unique_id = heater.serial_no self._attr_unique_id = heater.serial_no
@property @property
@override
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes.""" """Return the device state attributes."""
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS} return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
@property @property
@override
def current_temperature(self) -> float | None: def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
if self._heater.is_tapping: if self._heater.is_tapping:
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
return max(self._heater.heater_temp, self._heater.tap_temp) return max(self._heater.heater_temp, self._heater.tap_temp)
@property @property
@override
def current_operation(self) -> str | None: def current_operation(self) -> str | None:
"""Return the current operation mode.""" """Return the current operation mode."""
return self._heater.display_text return self._heater.display_text
-1
View File
@@ -81,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
def async_on_update(self, event: NodeProperty) -> None: def async_on_update(self, event: NodeProperty) -> None:
"""Save brightness in the update event from the ISY Node.""" """Save brightness in the update event from the ISY Node."""
if self._node.status not in (0, ISY_VALUE_UNKNOWN): if self._node.status not in (0, ISY_VALUE_UNKNOWN):
self._last_brightness = self._node.status
if self._node.uom == UOM_PERCENTAGE: if self._node.uom == UOM_PERCENTAGE:
self._last_brightness = round(self._node.status * 255.0 / 100.0) self._last_brightness = round(self._node.status * 255.0 / 100.0)
else: else:
@@ -279,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
if uom in (UOM_INDEX, UOM_ON_OFF): if uom in (UOM_INDEX, UOM_ON_OFF):
return cast(str, self.target.formatted) return cast(str, self.target.formatted)
# Check if this is an index type and get formatted value
if uom == UOM_INDEX and hasattr(self.target, "formatted"):
return cast(str, self.target.formatted)
# Handle ISY precision and rounding # Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self.target.prec) value = convert_isy_value_to_hass(value, uom, self.target.prec)
if value is None: if value is None:
@@ -8,8 +8,6 @@ import datetime
from functools import partial from functools import partial
from random import random from random import random
import voluptuous as vol
from homeassistant.components.labs import ( from homeassistant.components.labs import (
EventLabsUpdatedData, EventLabsUpdatedData,
async_is_preview_feature_enabled, async_is_preview_feature_enabled,
@@ -34,7 +32,7 @@ from homeassistant.const import (
UnitOfTemperature, UnitOfTemperature,
UnitOfVolume, UnitOfVolume,
) )
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.issue_registry import (
@@ -51,9 +49,11 @@ from homeassistant.util.unit_conversion import (
) )
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .services import async_setup_services
COMPONENTS_WITH_DEMO_PLATFORM = [ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON, Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.FAN, Platform.FAN,
Platform.EVENT, Platform.EVENT,
Platform.IMAGE, Platform.IMAGE,
@@ -69,15 +69,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the demo environment.""" """Set up the demo environment."""
@@ -87,24 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
) )
) )
@callback async_setup_services(hass)
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
"test_service_1",
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
return True return True
@@ -0,0 +1,97 @@
"""Demo platform that has a couple of fake device trackers."""
from homeassistant.components.device_tracker import (
BaseScannerEntity,
SourceType,
TrackerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
async_add_entities(
[
DemoTracker(
unique_id="kitchen_sink_tracker_001",
name="Demo tracker",
latitude=hass.config.latitude,
longitude=hass.config.longitude,
accuracy=10,
),
DemoScanner(
unique_id="kitchen_sink_scanner_001",
name="Demo scanner",
is_connected=True,
),
]
)
class DemoTracker(TrackerEntity):
"""Representation of a demo tracker."""
_attr_should_poll = False
_attr_source_type = SourceType.GPS
def __init__(
self,
*,
unique_id: str,
name: str,
latitude: float | None,
longitude: float | None,
accuracy: float,
) -> None:
"""Initialize the tracker."""
self._attr_unique_id = unique_id
self._attr_name = name
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
@callback
def async_set_tracker_location(
self, latitude: float, longitude: float, accuracy: float
) -> None:
"""Update the tracker location."""
self._attr_latitude = latitude
self._attr_longitude = longitude
self._attr_location_accuracy = accuracy
self.async_write_ha_state()
class DemoScanner(BaseScannerEntity):
"""Representation of a demo scanner."""
_attr_should_poll = False
_attr_source_type = SourceType.ROUTER
def __init__(
self,
*,
unique_id: str,
name: str,
is_connected: bool,
) -> None:
"""Initialize the scanner."""
self._attr_unique_id = unique_id
self._attr_name = name
self._is_connected = is_connected
@property
def is_connected(self) -> bool:
"""Return true if the device is connected."""
return self._is_connected
@callback
def async_set_scanner_connected(self, connected: bool) -> None:
"""Update the scanner connected state."""
self._is_connected = connected
self.async_write_ha_state()
@@ -9,6 +9,12 @@
} }
}, },
"services": { "services": {
"set_scanner_connected": {
"service": "mdi:lan-connect"
},
"set_tracker_location": {
"service": "mdi:map-marker"
},
"test_service_1": { "test_service_1": {
"sections": { "sections": {
"additional_fields": "mdi:test-tube" "additional_fields": "mdi:test-tube"
@@ -0,0 +1,72 @@
"""Services for the Everything but the Kitchen Sink integration."""
import voluptuous as vol
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
{
vol.Required("field_1"): vol.Coerce(int),
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
vol.Optional("field_3"): vol.Coerce(int),
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
}
)
SERVICE_TEST_SERVICE_1 = "test_service_1"
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
ATTR_ACCURACY = "accuracy"
ATTR_CONNECTED = "connected"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for the Kitchen Sink integration."""
@callback
def service_handler(call: ServiceCall) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN,
SERVICE_TEST_SERVICE_1,
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TRACKER_LOCATION,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={
vol.Required(ATTR_LATITUDE): cv.latitude,
vol.Required(ATTR_LONGITUDE): cv.longitude,
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
},
func="async_set_tracker_location",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_SCANNER_CONNECTED,
entity_domain=DEVICE_TRACKER_DOMAIN,
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
func="async_set_scanner_connected",
)
@@ -30,3 +30,44 @@ test_service_1:
options: options:
- "forward" - "forward"
- "reverse" - "reverse"
set_tracker_location:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
latitude:
required: true
example: 52.379189
selector:
number:
min: -90
max: 90
step: any
longitude:
required: true
example: 4.899431
selector:
number:
min: -180
max: 180
step: any
accuracy:
required: true
example: 10
selector:
number:
min: 0
max: 10000
unit_of_measurement: m
set_scanner_connected:
target:
entity:
integration: kitchen_sink
domain: device_tracker
fields:
connected:
required: true
example: true
selector:
boolean:
@@ -135,6 +135,34 @@
} }
}, },
"services": { "services": {
"set_scanner_connected": {
"description": "Sets the connected state of a demo scanner entity.",
"fields": {
"connected": {
"description": "Whether the device should be reported as connected.",
"name": "Connected"
}
},
"name": "Set scanner connected"
},
"set_tracker_location": {
"description": "Sets the location and accuracy of a demo tracker entity.",
"fields": {
"accuracy": {
"description": "Location accuracy in meters.",
"name": "Accuracy"
},
"latitude": {
"description": "Latitude of the new location.",
"name": "Latitude"
},
"longitude": {
"description": "Longitude of the new location.",
"name": "Longitude"
}
},
"name": "Set tracker location"
},
"test_service_1": { "test_service_1": {
"description": "Fake action for testing {meep_2}", "description": "Fake action for testing {meep_2}",
"fields": { "fields": {
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields: fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -5,7 +5,7 @@
fields: fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields .trigger_common_fields: &trigger_common_fields
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+103 -73
View File
@@ -1,7 +1,5 @@
"""Support for OPNsense Routers.""" """Support for OPNsense Routers."""
import logging
from aiopnsense import ( from aiopnsense import (
OPNsenseBelowMinFirmware, OPNsenseBelowMinFirmware,
OPNsenseClient, OPNsenseClient,
@@ -15,22 +13,16 @@ from aiopnsense import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import load_platform import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
CONF_API_SECRET, from .types import OPNsenseConfigEntry, OPNsenseRuntimeData
CONF_INTERFACE_CLIENT,
CONF_TRACKER_INTERFACES,
DOMAIN,
OPNSENSE_DATA,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@@ -49,86 +41,124 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the opnsense component.""" """Set up the OPNsense component."""
if DOMAIN not in config:
return True
conf = config[DOMAIN] hass.async_create_task(_async_setup(hass, config))
url = conf[CONF_URL]
api_key = conf[CONF_API_KEY]
api_secret = conf[CONF_API_SECRET]
verify_ssl = conf[CONF_VERIFY_SSL]
tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
session = async_get_clientsession(hass, verify_ssl=verify_ssl) return True
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the OPNsense component from YAML."""
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
async def async_setup_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Set up the OPNsense component from a config entry."""
url = config_entry.data[CONF_URL]
session = async_get_clientsession(
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
)
client = OPNsenseClient( client = OPNsenseClient(
url, url,
api_key, config_entry.data[CONF_API_KEY],
api_secret, config_entry.data[CONF_API_SECRET],
session, session,
opts={"verify_ssl": verify_ssl}, opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]},
) )
tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, [])
try: try:
await client.validate() await client.validate()
if tracker_interfaces: if tracker_interfaces:
interfaces_resp = await client.get_interfaces() interfaces_resp = await client.get_interfaces()
except OPNsenseUnknownFirmware: except OPNsenseUnknownFirmware as err:
_LOGGER.error("Error checking the OPNsense firmware version at %s", url) raise ConfigEntryError(
return False translation_domain=DOMAIN,
except OPNsenseBelowMinFirmware: translation_key="unknown_firmware",
_LOGGER.error( translation_placeholders={"url": url},
"OPNsense Firmware is below the minimum supported version at %s", url ) from err
) except OPNsenseBelowMinFirmware as err:
return False raise ConfigEntryError(
except OPNsenseInvalidURL: translation_domain=DOMAIN,
_LOGGER.error( translation_key="firmware_too_old",
"Invalid URL while connecting to OPNsense API endpoint at %s", url translation_placeholders={"url": url},
) ) from err
return False except OPNsenseInvalidURL as err:
except OPNsenseTimeoutError: raise ConfigEntryError(
_LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url) translation_domain=DOMAIN,
return False translation_key="invalid_url",
except OPNsenseSSLError: translation_placeholders={"url": url},
_LOGGER.error( ) from err
"Unable to verify SSL while connecting to OPNsense API endpoint at %s", url except OPNsenseTimeoutError as err:
) raise ConfigEntryNotReady(
return False translation_domain=DOMAIN,
except OPNsenseInvalidAuth: translation_key="timeout_connecting",
_LOGGER.error( translation_placeholders={"url": url},
"Authentication failure while connecting to OPNsense API endpoint at %s", ) from err
url, except OPNsenseSSLError as err:
) raise ConfigEntryError(
return False translation_domain=DOMAIN,
except OPNsensePrivilegeMissing: translation_key="ssl_error",
_LOGGER.error( translation_placeholders={"url": url},
"Invalid Permissions while connecting to OPNsense API endpoint at %s", ) from err
url, except OPNsenseInvalidAuth as err:
) raise ConfigEntryError(
return False translation_domain=DOMAIN,
except OPNsenseConnectionError: translation_key="invalid_auth",
_LOGGER.error( translation_placeholders={"url": url},
"Connection failure while connecting to OPNsense API endpoint at %s", ) from err
url, except OPNsensePrivilegeMissing as err:
) raise ConfigEntryError(
return False translation_domain=DOMAIN,
translation_key="privilege_missing",
translation_placeholders={"url": url},
) from err
except OPNsenseConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"url": url},
) from err
if tracker_interfaces: if tracker_interfaces:
# Verify that specified tracker interfaces are valid # Verify that specified tracker interfaces are valid
known_interfaces = [ known_interfaces = [
ifinfo.get("name", "") for ifinfo in interfaces_resp.values() name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name"))
] ]
for intf_description in tracker_interfaces: for intf_description in tracker_interfaces:
if intf_description not in known_interfaces: if intf_description not in known_interfaces:
_LOGGER.error( raise ConfigEntryError(
"Specified OPNsense tracker interface %s is not found", translation_domain=DOMAIN,
intf_description, translation_key="tracker_interface_not_found",
translation_placeholders={
"interface": intf_description,
"known": ", ".join(known_interfaces),
},
) )
return False
hass.data[OPNSENSE_DATA] = { config_entry.runtime_data = OPNsenseRuntimeData(
CONF_INTERFACE_CLIENT: client, client=client,
CONF_TRACKER_INTERFACES: tracker_interfaces, tracker_interfaces=tracker_interfaces,
} )
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@@ -0,0 +1,315 @@
"""Config flow for OPNsense."""
import logging
from typing import Any
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_API_SECRET): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
)
def tracker_interfaces_schema(
interfaces: list[str], selected: list[str] | None = None
) -> vol.Schema:
"""Schema to display available interfaces for device tracking selection."""
return vol.Schema(
{
vol.Optional(
CONF_TRACKER_INTERFACES,
default=selected or [],
): SelectSelector(
SelectSelectorConfig(
options=interfaces, mode=SelectSelectorMode.DROPDOWN, multiple=True
)
),
}
)
class OPNsenseConfigFlow(ConfigFlow, domain=DOMAIN):
"""OPNsense config flow."""
def __init__(self) -> None:
"""Initialize OPNsense config flow."""
self.available_interfaces: list[str] | None = None
self._entry_data: dict[str, Any] = {}
async def _show_setup_form(
self,
user_input: dict[Any, Any] | None = None,
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
description_placeholders = {
"doc_url": "https://www.home-assistant.io/integrations/opnsense/"
}
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors or {},
description_placeholders=description_placeholders,
)
async def _show_interfaces_form(
self,
user_input: dict[Any, Any],
errors: dict[Any, Any] | None = None,
) -> ConfigFlowResult:
"""Show the tracker interfaces selection form to the user."""
return self.async_show_form(
step_id="interfaces",
data_schema=self.add_suggested_values_to_schema(
tracker_interfaces_schema(
self.available_interfaces or [],
user_input.get(CONF_TRACKER_INTERFACES),
),
user_input,
),
errors=errors or {},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user step: credentials and connection test."""
errors = {}
if user_input is None:
return await self._show_setup_form(user_input, None)
verify_ssl = user_input[CONF_VERIFY_SSL]
session = async_get_clientsession(self.hass, verify_ssl=verify_ssl)
client = OPNsenseClient(
user_input[CONF_URL],
user_input[CONF_API_KEY],
user_input[CONF_API_SECRET],
session,
opts={"verify_ssl": verify_ssl},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = list(known_interfaces)
except OPNsenseInvalidAuth:
errors["base"] = "invalid_auth"
except OPNsensePrivilegeMissing:
errors["base"] = "privilege_missing"
except OPNsenseInvalidURL:
errors["base"] = "invalid_url"
except OPNsenseSSLError:
errors["base"] = "ssl_error"
except OPNsenseConnectionError, OPNsenseTimeoutError:
errors["base"] = "cannot_connect"
except OPNsenseUnknownFirmware:
errors["base"] = "unknown_version"
except OPNsenseBelowMinFirmware:
errors["base"] = "invalid_version"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
unique_id = await client.get_device_unique_id()
if not unique_id:
return self.async_abort(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
self._entry_data = user_input
return await self.async_step_interfaces()
return await self._show_setup_form(user_input, errors)
async def async_step_interfaces(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle tracker interface selection step."""
if user_input is None:
return await self._show_interfaces_form({}, None)
if user_input.get(CONF_TRACKER_INTERFACES):
self._entry_data[CONF_TRACKER_INTERFACES] = user_input[
CONF_TRACKER_INTERFACES
]
return self.async_create_entry(
title=self._entry_data[CONF_URL], data=self._entry_data
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a Yaml config."""
# Test connection
session = async_get_clientsession(
self.hass, verify_ssl=import_data[CONF_VERIFY_SSL]
)
client = OPNsenseClient(
import_data[CONF_URL],
import_data[CONF_API_KEY],
import_data[CONF_API_SECRET],
session,
opts={"verify_ssl": import_data[CONF_VERIFY_SSL]},
)
try:
await client.validate()
interfaces_resp = await client.get_interfaces()
except OPNsenseInvalidURL:
return self._abort_import(reason="invalid_url")
except OPNsenseInvalidAuth:
return self._abort_import(reason="invalid_auth")
except OPNsensePrivilegeMissing:
return self._abort_import(reason="privilege_missing")
except OPNsenseSSLError:
return self._abort_import(reason="ssl_error")
except OPNsenseConnectionError, OPNsenseTimeoutError:
return self._abort_import(reason="cannot_connect")
except OPNsenseUnknownFirmware:
return self._abort_import(reason="unknown_version")
except OPNsenseBelowMinFirmware:
return self._abort_import(reason="invalid_version")
except Exception: # Allowed in config flows
_LOGGER.exception("Unexpected exception during import")
return self._abort_import(reason="unknown")
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
unique_id = await client.get_device_unique_id()
if not unique_id:
return self._abort_import(reason="no_unique_id")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Validate CONF_TRACKER_INTERFACES if present and not empty
verified_data = dict(import_data)
if CONF_TRACKER_INTERFACES in verified_data:
if not verified_data[CONF_TRACKER_INTERFACES]:
verified_data.pop(CONF_TRACKER_INTERFACES)
else:
known_interfaces = [
name
for ifinfo in interfaces_resp.values()
if (name := ifinfo.get("name"))
]
self.available_interfaces = sorted(known_interfaces)
# Abort import if any specified tracker interface is not found
missing = [
intf_description
for intf_description in verified_data[CONF_TRACKER_INTERFACES]
if intf_description not in known_interfaces
]
if missing:
# Create a repair to guide the user
async_create_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="import_failed_missing_interfaces",
translation_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason="import_failed_missing_interfaces",
description_placeholders={
"missing": ", ".join(missing),
"found": ", ".join(known_interfaces),
"integration_title": "OPNsense",
},
)
# Clear any previous import issues if interfaces are now valid
async_delete_issue(
self.hass,
DOMAIN,
"import_failed_missing_interfaces",
)
return self.async_create_entry(
title=verified_data[CONF_URL], data=verified_data
)
def _abort_import(self, reason: str) -> ConfigFlowResult:
"""Create an issue for import errors and abort the import."""
async_create_issue(
self.hass,
DOMAIN,
f"import_failed_{reason}",
breaks_in_ha_version="2026.12.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key=f"import_failed_{reason}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "OPNsense",
},
)
return self.async_abort(
reason=reason,
description_placeholders={
"integration_title": "OPNsense",
},
)
+5 -2
View File
@@ -1,8 +1,11 @@
"""Constants for OPNsense component.""" """Constants for OPNsense component."""
from datetime import timedelta
DOMAIN = "opnsense" DOMAIN = "opnsense"
OPNSENSE_DATA = DOMAIN
CONF_API_SECRET = "api_secret" CONF_API_SECRET = "api_secret"
CONF_INTERFACE_CLIENT = "interface_client"
CONF_TRACKER_INTERFACES = "tracker_interfaces" CONF_TRACKER_INTERFACES = "tracker_interfaces"
# Update interval for device scanning
SCAN_INTERVAL = timedelta(seconds=30)
@@ -0,0 +1,80 @@
"""Coordinator for OPNsense device tracker updates."""
import logging
from aiopnsense import (
OPNsenseBelowMinFirmware,
OPNsenseClient,
OPNsenseConnectionError,
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseTimeoutError,
OPNsenseUnknownFirmware,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import SCAN_INTERVAL
from .types import DeviceDetails, DeviceDetailsByMAC, OPNsenseConfigEntry
_LOGGER = logging.getLogger(__name__)
class OPNsenseDeviceTrackerCoordinator(DataUpdateCoordinator[DeviceDetailsByMAC]):
"""Coordinator for OPNsense device tracker updates."""
def __init__(
self,
hass: HomeAssistant,
config_entry: OPNsenseConfigEntry,
client: OPNsenseClient,
interfaces: list[str],
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="OPNsense Device Tracker",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
self.interfaces = interfaces
self.tracked_devices: set[str] = set()
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC:
"""Create dict with mac address keys from list of devices."""
out_devices: DeviceDetailsByMAC = {}
for device in devices:
if not self.interfaces or device["intf_description"] in self.interfaces:
formatted_mac = format_mac(device["mac"])
out_devices[formatted_mac] = device
return out_devices
async def _async_update_data(self) -> DeviceDetailsByMAC:
"""Fetch data from OPNsense."""
try:
devices = await self.client.get_arp_table(True)
except (
OPNsenseInvalidAuth,
OPNsenseInvalidURL,
OPNsensePrivilegeMissing,
OPNsenseSSLError,
OPNsenseBelowMinFirmware,
OPNsenseUnknownFirmware,
) as err:
raise ConfigEntryError(f"Error with OPNsense configuration: {err}") from err
except (
OPNsenseConnectionError,
OPNsenseTimeoutError,
) as err:
raise UpdateFailed(
f"Error communicating with OPNsense router: {err}"
) from err
return self._get_mac_addrs(devices)
@@ -1,71 +1,117 @@
"""Device tracker support for OPNsense routers.""" """Device tracker support for OPNsense routers."""
from typing import Any, NewType from typing import Any
from aiopnsense import OPNsenseClient from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.components.device_tracker import DeviceScanner
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_INTERFACE_CLIENT, CONF_TRACKER_INTERFACES, OPNSENSE_DATA from .coordinator import OPNsenseDeviceTrackerCoordinator
from .types import DeviceDetails, OPNsenseConfigEntry
DeviceDetails = NewType("DeviceDetails", dict[str, Any])
DeviceDetailsByMAC = NewType("DeviceDetailsByMAC", dict[str, DeviceDetails])
async def async_get_scanner( async def async_setup_entry(
hass: HomeAssistant, config: ConfigType hass: HomeAssistant,
) -> DeviceScanner | None: entry: OPNsenseConfigEntry,
"""Configure the OPNsense device_tracker.""" async_add_entities: AddConfigEntryEntitiesCallback,
return OPNsenseDeviceScanner( ) -> None:
hass.data[OPNSENSE_DATA][CONF_INTERFACE_CLIENT], """Set up device tracker for OPNsense component."""
hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACES], client = entry.runtime_data.client
) interfaces = entry.runtime_data.tracker_interfaces
coordinator = OPNsenseDeviceTrackerCoordinator(hass, entry, client, interfaces)
def _async_add_new_entities() -> None:
"""Add entities for newly discovered devices."""
if not coordinator.data:
return
entities = []
for mac_address in coordinator.data:
if mac_address in coordinator.tracked_devices:
continue
entity = OPNsenseDeviceTrackerEntity(coordinator, mac_address)
coordinator.tracked_devices.add(mac_address)
entities.append(entity)
if entities:
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
# Initial data fetch
await coordinator.async_config_entry_first_refresh()
_async_add_new_entities()
class OPNsenseDeviceScanner(DeviceScanner): class OPNsenseDeviceTrackerEntity(
"""This class queries a router running OPNsense.""" CoordinatorEntity[OPNsenseDeviceTrackerCoordinator], ScannerEntity
):
"""Representation of a tracked device."""
def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None: def __init__(
"""Initialize the scanner.""" self,
self.last_results: dict[str, Any] = {} coordinator: OPNsenseDeviceTrackerCoordinator,
self.client = client mac_address: str,
self.interfaces = interfaces ) -> None:
"""Initialize the device tracker entity."""
super().__init__(coordinator)
self._attr_mac_address = mac_address
def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | dict: @property
"""Create dict with mac address keys from list of devices.""" def device_data(self) -> DeviceDetails | None:
out_devices = {} """Return device data for current device."""
for device in devices: if self.coordinator.data and self.mac_address in self.coordinator.data:
if not self.interfaces or device["intf_description"] in self.interfaces: return self.coordinator.data[self.mac_address]
out_devices[device["mac"]] = device return None
return out_devices
async def async_scan_devices(self) -> list[str]: @property
"""Scan for new devices and return a list with found device IDs.""" def is_connected(self) -> bool:
await self._async_update_info() """Return true if the device is connected to the network."""
return list(self.last_results) return (
self.coordinator.data is not None
and self.mac_address in self.coordinator.data
)
def get_device_name(self, device: str) -> str | None: @property
"""Return the name of the given device or None if we don't know.""" def name(self) -> str:
if device not in self.last_results: """Return device name."""
return None device_data = self.device_data
return self.last_results[device].get("hostname") or None if device_data and device_data.get("hostname"):
return str(device_data["hostname"])
return f"OPNsense {self.mac_address}"
async def _async_update_info(self) -> bool: @property
"""Ensure the information from the OPNsense router is up to date. def ip_address(self) -> str | None:
"""Return the primary IP address of the device."""
device_data = self.device_data
if device_data:
return device_data.get("ip")
return None
Return boolean if scanning successful. @property
""" def hostname(self) -> str | None:
devices = await self.client.get_arp_table(True) """Return hostname of the device."""
self.last_results = self._get_mac_addrs(devices) device_data = self.device_data
return True if device_data:
hostname = device_data.get("hostname")
return hostname or None
return None
def get_extra_attributes(self, device: str) -> dict[Any, Any]: @property
"""Return the extra attrs of the given device.""" def extra_state_attributes(self) -> dict[str, Any]:
if device not in self.last_results: """Return the state attributes."""
device_data = self.device_data
if not device_data:
return {} return {}
mfg = self.last_results[device].get("manufacturer")
if not mfg: attrs = {}
return {} if manufacturer := device_data.get("manufacturer"):
return {"manufacturer": mfg} attrs["manufacturer"] = manufacturer
if interface := device_data.get("intf_description"):
attrs["interface"] = interface
if expires := device_data.get("expires"):
attrs["expires"] = expires
return attrs
@@ -2,6 +2,7 @@
"domain": "opnsense", "domain": "opnsense",
"name": "OPNsense", "name": "OPNsense",
"codeowners": ["@HarlemSquirrel", "@Snuffy2"], "codeowners": ["@HarlemSquirrel", "@Snuffy2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/opnsense", "documentation": "https://www.home-assistant.io/integrations/opnsense",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -0,0 +1,117 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"import_failed_missing_interfaces": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_url": "URL is invalid or unreachable",
"invalid_version": "Unsupported OPNsense firmware version",
"no_unique_id": "Could not determine a unique identifier for this OPNsense router. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
"privilege_missing": "The API key used does not have sufficient privileges. Please check the integration documentation for required permissions",
"ssl_error": "SSL certificate verification failed",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_interface": "Interface(s) do not exist",
"invalid_url": "URL is invalid or unreachable",
"invalid_version": "Unsupported OPNsense firmware version",
"privilege_missing": "[%key:component::opnsense::config::abort::privilege_missing%]",
"ssl_error": "SSL certificate verification failed",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_version": "The OPNsense firmware version is unknown. Please ensure you are running a supported version of OPNsense and the user has the correct permissions."
},
"step": {
"interfaces": {
"data": {
"tracker_interfaces": "Interface(s) to use for tracking devices"
},
"description": "Select the OPNsense interfaces to use for tracking devices. If no interfaces are selected then all interfaces will be used for tracking."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_secret": "API secret",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Set required parameters to connect to your router. For more information, please refer to the [integration documentation]({doc_url})"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Connection failure while connecting to OPNsense API endpoint at {url}"
},
"firmware_too_old": {
"message": "OPNsense firmware at {url} is below the minimum supported version"
},
"invalid_auth": {
"message": "Authentication failure while connecting to OPNsense API endpoint at {url}"
},
"invalid_url": {
"message": "Invalid URL while connecting to OPNsense API endpoint at {url}"
},
"privilege_missing": {
"message": "The API user connecting to {url} does not have sufficient privileges"
},
"ssl_error": {
"message": "Unable to verify SSL certificate while connecting to OPNsense API endpoint at {url}"
},
"timeout_connecting": {
"message": "Timeout while connecting to OPNsense API endpoint at {url}"
},
"tracker_interface_not_found": {
"message": "Configured tracker interface {interface} is not present on the OPNsense router. Known interfaces: {known}"
},
"unknown_firmware": {
"message": "Could not determine the OPNsense firmware version at {url}"
}
},
"issues": {
"import_failed_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an authentication error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_url": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the URL provided is invalid or unreachable. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_invalid_version": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unsupported. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_missing_interfaces": {
"description": "The following OPNsense tracker interfaces were not found: {missing}. Found interfaces were: {found}. Please manually set up a config entry and remove the YAML config",
"title": "The {integration_title} YAML import failed: Missing tracker interfaces"
},
"import_failed_no_unique_id": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a unique identifier for the router could not be determined. Please ensure the API key has sufficient privileges, and your OPNsense instance has physical ports with a MAC address.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_privilege_missing": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the API key used does not have sufficient privileges. Please check the integration documentation for required permissions, correct your YAML configuration, and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_ssl_error": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_unknown": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"import_failed_unknown_version": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an error occurred indicating the OPNsense firmware version is unknown. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
}
}
}
@@ -0,0 +1,21 @@
"""Types for OPNsense routers."""
from dataclasses import dataclass
from typing import Any
from aiopnsense import OPNsenseClient
from homeassistant.config_entries import ConfigEntry
@dataclass(slots=True)
class OPNsenseRuntimeData:
"""Runtime data for OPNsense config entries."""
client: OPNsenseClient
tracker_interfaces: list[str]
type DeviceDetails = dict[str, Any]
type DeviceDetailsByMAC = dict[str, DeviceDetails]
type OPNsenseConfigEntry = ConfigEntry[OPNsenseRuntimeData]
@@ -111,8 +111,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
except CannotConnect as err: except CannotConnect as err:
_LOGGER.error("Error during login: %s", err) _LOGGER.error("Error during login: %s", err)
# pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed(
raise UpdateFailed(f"Error during login: {err}") from err translation_domain=DOMAIN,
translation_key="login_error",
translation_placeholders={"error": str(err)},
) from err
try: try:
accounts = await self.api.async_get_accounts() accounts = await self.api.async_get_accounts()
@@ -124,6 +124,11 @@
} }
} }
}, },
"exceptions": {
"login_error": {
"message": "Error during login: {error}"
}
},
"issues": { "issues": {
"return_to_grid_migration": { "return_to_grid_migration": {
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.", "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.",
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: .trigger_common_fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+12 -1
View File
@@ -50,8 +50,10 @@ class QbusWeatherDescription(SensorEntityDescription):
"""Description for Qbus weather entities.""" """Description for Qbus weather entities."""
property: str property: str
scale_factor: int | None = None
# Qbus reports illuminance in klux, HA only supports lux.
_WEATHER_DESCRIPTIONS = ( _WEATHER_DESCRIPTIONS = (
QbusWeatherDescription( QbusWeatherDescription(
key="daylight", key="daylight",
@@ -60,6 +62,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX, native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
), ),
QbusWeatherDescription( QbusWeatherDescription(
key="light", key="light",
@@ -67,6 +70,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX, native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
), ),
QbusWeatherDescription( QbusWeatherDescription(
key="light_east", key="light_east",
@@ -75,6 +79,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX, native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
), ),
QbusWeatherDescription( QbusWeatherDescription(
key="light_south", key="light_south",
@@ -83,6 +88,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX, native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
), ),
QbusWeatherDescription( QbusWeatherDescription(
key="light_west", key="light_west",
@@ -91,6 +97,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX, native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
), ),
QbusWeatherDescription( QbusWeatherDescription(
key="temperature", key="temperature",
@@ -400,4 +407,8 @@ class QbusWeatherSensor(QbusEntity, SensorEntity):
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
if value := state.read_property(self.entity_description.property, None): if value := state.read_property(self.entity_description.property, None):
self.native_value = value self.native_value = (
value * self.entity_description.scale_factor
if self.entity_description.scale_factor is not None
else value
)
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/qingping", "documentation": "https://www.home-assistant.io/integrations/qingping",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["qingping-ble==1.1.4"] "requirements": ["qingping-ble==1.1.5"]
} }
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -24,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY, CONF_BC_ONLY,
CONF_BC_PORT, CONF_BC_PORT,
CONF_FIRMWARE_CHECK_TIME, CONF_FIRMWARE_CHECK_TIME,
@@ -102,6 +103,8 @@ async def async_setup_entry(
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
or host.api.baichuan.connection_type.value
!= config_entry.data.get(CONF_BC_CONNECT)
): ):
if host.api.port != config_entry.data[CONF_PORT]: if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning( _LOGGER.warning(
@@ -126,6 +129,7 @@ async def async_setup_entry(
CONF_USE_HTTPS: host.api.use_https, CONF_USE_HTTPS: host.api.use_https,
CONF_BC_PORT: host.api.baichuan.port, CONF_BC_PORT: host.api.baichuan.port,
CONF_BC_ONLY: host.api.baichuan_only, CONF_BC_ONLY: host.api.baichuan_only,
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
} }
hass.config_entries.async_update_entry(config_entry, data=data) hass.config_entries.async_update_entry(config_entry, data=data)
@@ -37,6 +37,7 @@ from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import ( from .const import (
CONF_BC_CONNECT,
CONF_BC_ONLY, CONF_BC_ONLY,
CONF_BC_PORT, CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE, CONF_SUPPORTS_PRIVACY_MODE,
@@ -310,6 +311,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_USE_HTTPS] = host.api.use_https user_input[CONF_USE_HTTPS] = host.api.use_https
user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_BC_PORT] = host.api.baichuan.port
user_input[CONF_BC_ONLY] = host.api.baichuan_only user_input[CONF_BC_ONLY] = host.api.baichuan_only
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode" None, "privacy_mode"
) )
@@ -7,6 +7,7 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https" CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port" CONF_BC_PORT = "baichuan_port"
CONF_BC_ONLY = "baichuan_only" CONF_BC_ONLY = "baichuan_only"
CONF_BC_CONNECT = "baichuan_connection"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
+9 -1
View File
@@ -11,7 +11,7 @@ import aiohttp
from aiohttp.web import Request from aiohttp.web import Request
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.baichuan import DEFAULT_BC_PORT
from reolink_aio.enums import SubType from reolink_aio.enums import ConnectionEnum, SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components import webhook from homeassistant.components import webhook
@@ -36,6 +36,7 @@ from .const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
BATTERY_WAKE_UPDATE_INTERVAL, BATTERY_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY, CONF_BC_ONLY,
CONF_BC_PORT, CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE, CONF_SUPPORTS_PRIVACY_MODE,
@@ -77,6 +78,12 @@ class ReolinkHost:
self._config_entry = config_entry self._config_entry = config_entry
self._config = config self._config = config
self._unique_id: str = "" self._unique_id: str = ""
try:
bc_connection = ConnectionEnum(
config.get(CONF_BC_CONNECT, ConnectionEnum.unknown.value)
)
except ValueError:
bc_connection = ConnectionEnum.unknown
def get_aiohttp_session() -> aiohttp.ClientSession: def get_aiohttp_session() -> aiohttp.ClientSession:
"""Return the HA aiohttp session.""" """Return the HA aiohttp session."""
@@ -96,6 +103,7 @@ class ReolinkHost:
timeout=DEFAULT_TIMEOUT, timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=get_aiohttp_session, aiohttp_get_session_callback=get_aiohttp_session,
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
bc_connection=bc_connection,
bc_only=config.get(CONF_BC_ONLY, False), bc_only=config.get(CONF_BC_ONLY, False),
) )
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -6,7 +6,7 @@
fields: fields:
behavior: behavior:
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -217,9 +217,9 @@
"home": "[%key:common::entity::button::home::name%]", "home": "[%key:common::entity::button::home::name%]",
"insert": "Insert", "insert": "Insert",
"left": "[%key:common::entity::button::left::name%]", "left": "[%key:common::entity::button::left::name%]",
"lights_kbd_down": "Keyboasrd backlight brightness down", "lights_kbd_down": "Keyboard backlight brightness down",
"lights_kbd_toggle": "Toggle keyboard backlight", "lights_kbd_toggle": "Toggle keyboard backlight",
"lights_kbd_up": "Keyboard backlight brighness up", "lights_kbd_up": "Keyboard backlight brightness up",
"lights_mon_down": "Display brightness down", "lights_mon_down": "Display brightness down",
"lights_mon_up": "Display brightness up", "lights_mon_up": "Display brightness up",
"numpad_0": "NumPad 0", "numpad_0": "NumPad 0",
@@ -1,7 +1,7 @@
.trigger_common_fields: .trigger_common_fields:
behavior: &trigger_behavior behavior: &trigger_behavior
required: true required: true
default: any default: each
selector: selector:
automation_behavior: automation_behavior:
mode: trigger mode: trigger
@@ -18,6 +18,7 @@ from homeassistant.components.blueprint import (
) )
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN
@@ -59,6 +60,7 @@ from . import (
binary_sensor as binary_sensor_platform, binary_sensor as binary_sensor_platform,
button as button_platform, button as button_platform,
cover as cover_platform, cover as cover_platform,
device_tracker as device_tracker_platform,
event as event_platform, event as event_platform,
fan as fan_platform, fan as fan_platform,
image as image_platform, image as image_platform,
@@ -199,6 +201,9 @@ CONFIG_SECTION_SCHEMA = vol.All(
vol.Optional(COVER_DOMAIN): vol.All( vol.Optional(COVER_DOMAIN): vol.All(
cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA]
), ),
vol.Optional(DEVICE_TRACKER_DOMAIN): vol.All(
cv.ensure_list, [device_tracker_platform.TRACKER_YAML_SCHEMA]
),
vol.Optional(EVENT_DOMAIN): vol.All( vol.Optional(EVENT_DOMAIN): vol.All(
cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA] cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA]
), ),
@@ -23,6 +23,8 @@ from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_DEVICE_ID, CONF_DEVICE_ID,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME, CONF_NAME,
CONF_STATE, CONF_STATE,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
@@ -74,6 +76,11 @@ from .cover import (
STOP_ACTION, STOP_ACTION,
async_create_preview_cover, async_create_preview_cover,
) )
from .device_tracker import (
CONF_IN_ZONES,
CONF_LOCATION_ACCURACY,
async_create_preview_tracker,
)
from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event
from .fan import ( from .fan import (
CONF_OFF_ACTION, CONF_OFF_ACTION,
@@ -150,6 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = {
def generate_schema(domain: str, flow_type: str) -> vol.Schema: def generate_schema(domain: str, flow_type: str) -> vol.Schema:
"""Generate schema.""" """Generate schema."""
schema: dict[vol.Marker, Any] = {} schema: dict[vol.Marker, Any] = {}
advanced_options: dict[vol.Marker, Any] = {}
if flow_type == "config": if flow_type == "config":
schema = {vol.Required(CONF_NAME): selector.TextSelector()} schema = {vol.Required(CONF_NAME): selector.TextSelector()}
@@ -226,6 +234,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
) )
} }
if domain == Platform.DEVICE_TRACKER:
schema |= {
vol.Optional(CONF_IN_ZONES): selector.TemplateSelector(),
vol.Optional(CONF_LATITUDE): selector.TemplateSelector(),
vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(),
}
advanced_options |= {
vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(),
}
if domain == Platform.EVENT: if domain == Platform.EVENT:
schema |= { schema |= {
vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(), vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(),
@@ -431,6 +449,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Schema( vol.Schema(
{ {
vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(),
**advanced_options,
} }
), ),
{"collapsed": True}, {"collapsed": True},
@@ -540,6 +559,7 @@ TEMPLATE_TYPES = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.COVER, Platform.COVER,
Platform.DEVICE_TRACKER,
Platform.EVENT, Platform.EVENT,
Platform.FAN, Platform.FAN,
Platform.IMAGE, Platform.IMAGE,
@@ -575,6 +595,11 @@ CONFIG_FLOW = {
preview="template", preview="template",
validate_user_input=validate_user_input(Platform.COVER), validate_user_input=validate_user_input(Platform.COVER),
), ),
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
config_schema(Platform.DEVICE_TRACKER),
preview="template",
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
),
Platform.EVENT: SchemaFlowFormStep( Platform.EVENT: SchemaFlowFormStep(
config_schema(Platform.EVENT), config_schema(Platform.EVENT),
preview="template", preview="template",
@@ -660,6 +685,11 @@ OPTIONS_FLOW = {
preview="template", preview="template",
validate_user_input=validate_user_input(Platform.COVER), validate_user_input=validate_user_input(Platform.COVER),
), ),
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
options_schema(Platform.DEVICE_TRACKER),
preview="template",
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
),
Platform.EVENT: SchemaFlowFormStep( Platform.EVENT: SchemaFlowFormStep(
options_schema(Platform.EVENT), options_schema(Platform.EVENT),
preview="template", preview="template",
@@ -730,6 +760,7 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel,
Platform.BINARY_SENSOR: async_create_preview_binary_sensor, Platform.BINARY_SENSOR: async_create_preview_binary_sensor,
Platform.COVER: async_create_preview_cover, Platform.COVER: async_create_preview_cover,
Platform.DEVICE_TRACKER: async_create_preview_tracker,
Platform.EVENT: async_create_preview_event, Platform.EVENT: async_create_preview_event,
Platform.FAN: async_create_preview_fan, Platform.FAN: async_create_preview_fan,
Platform.LIGHT: async_create_preview_light, Platform.LIGHT: async_create_preview_light,
@@ -26,6 +26,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.COVER, Platform.COVER,
Platform.DEVICE_TRACKER,
Platform.EVENT, Platform.EVENT,
Platform.FAN, Platform.FAN,
Platform.IMAGE, Platform.IMAGE,
@@ -0,0 +1,250 @@
"""Support for device trackers which integrates with other components."""
from collections.abc import Callable
from typing import Any
import voluptuous as vol
from homeassistant.components import zone
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
ENTITY_ID_FORMAT,
TrackerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator, validators as template_validators
from .entity import AbstractTemplateEntity
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
make_template_entity_common_modern_schema,
)
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
DEFAULT_NAME = "Template Device Tracker"
CONF_IN_ZONES = "in_zones"
CONF_LOCATION_ACCURACY = "location_accuracy"
def _validate_in_zones_or_lat_and_lon(obj: dict) -> dict:
if CONF_IN_ZONES not in obj:
if CONF_LATITUDE not in obj or CONF_LONGITUDE not in obj:
raise vol.Invalid(
f"Either '{CONF_IN_ZONES}' or both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
)
elif (CONF_LATITUDE in obj and CONF_LONGITUDE not in obj) or (
CONF_LATITUDE not in obj and CONF_LONGITUDE in obj
):
raise vol.Invalid(
f"Both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
)
return obj
def validate_in_zones(
entity: AbstractTemplateTracker,
) -> Callable[[Any], list[str] | None]:
"""Convert the result to a list of entity_ids.
This ensures the result is a list of zone entity_ids.
All other values that are not lists will result in None.
"""
def convert(result: Any) -> list[str] | None:
if template_validators.check_result_for_none(result):
return None
if not isinstance(result, list):
template_validators.log_validation_result_error(
entity,
CONF_IN_ZONES,
result,
"expected a list of zone entity_ids",
)
return None
zone_entity_ids = []
failed = []
for v in result:
try:
zone_entity_ids.append(
vol.All(cv.entity_id, cv.entity_domain(zone.DOMAIN))(v)
)
except vol.Invalid:
failed.append(v)
if failed:
template_validators.log_validation_result_error(
entity,
CONF_IN_ZONES,
failed,
"expected a list of zone entity_ids",
)
return zone_entity_ids
return convert
TRACKER_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_IN_ZONES): cv.template,
vol.Optional(CONF_LATITUDE): cv.template,
vol.Optional(CONF_LOCATION_ACCURACY): cv.template,
vol.Optional(CONF_LONGITUDE): cv.template,
}
)
TRACKER_YAML_SCHEMA = vol.All(
_validate_in_zones_or_lat_and_lon,
TRACKER_COMMON_SCHEMA.extend(
make_template_entity_common_modern_schema(
DEVICE_TRACKER_DOMAIN, DEFAULT_NAME
).schema
),
)
TRACKER_CONFIG_ENTRY_SCHEMA = vol.All(
_validate_in_zones_or_lat_and_lon,
TRACKER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema),
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template device trackers."""
await async_setup_template_platform(
hass,
DEVICE_TRACKER_DOMAIN,
config,
StateTrackerEntity,
TriggerTrackerEntity,
async_add_entities,
discovery_info,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await async_setup_template_entry(
hass,
config_entry,
async_add_entities,
StateTrackerEntity,
TRACKER_CONFIG_ENTRY_SCHEMA,
)
@callback
def async_create_preview_tracker(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> StateTrackerEntity:
"""Create a preview device tracker."""
return async_setup_template_preview(
hass,
name,
config,
StateTrackerEntity,
TRACKER_CONFIG_ENTRY_SCHEMA,
)
class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity):
"""Representation of a template device tracker features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity
# and TriggerEntity will call
# AbstractTemplateEntity.__init__. This ensures that
# the __init__ on AbstractTemplateEntity is not
# called twice.
def __init__(self) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self.setup_template(
CONF_IN_ZONES,
"_attr_in_zones",
validate_in_zones(self),
)
self.setup_template(
CONF_LATITUDE,
"_attr_latitude",
template_validators.number(self, CONF_LATITUDE, -90.0, 90.0),
)
self.setup_template(
CONF_LONGITUDE,
"_attr_longitude",
template_validators.number(self, CONF_LONGITUDE, -180.0, 180.0),
)
self.setup_template(
CONF_LOCATION_ACCURACY,
"_attr_location_accuracy",
on_update=self._update_location_accuracy,
none_on_template_error=False,
)
self._location_accuracy_validator = template_validators.number(
self, CONF_LOCATION_ACCURACY, 0.0
)
def _update_location_accuracy(self, value: float | None) -> None:
"""Update the location accuracy."""
self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0
class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker):
"""Representation of a Template device tracker."""
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
unique_id: str | None,
) -> None:
"""Initialize the Template device tracker."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateTracker.__init__(self)
class TriggerTrackerEntity(TriggerEntity, AbstractTemplateTracker):
"""Tracker entity based on trigger data."""
domain = DEVICE_TRACKER_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateTracker.__init__(self)
+55 -1
View File
@@ -136,6 +136,34 @@
}, },
"title": "Template cover" "title": "Template cover"
}, },
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
"name": "[%key:common::config_flow::data::name%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
"location_accuracy": "Location accuracy"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]",
"location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`."
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "Template device tracker"
},
"event": { "event": {
"data": { "data": {
"device_class": "[%key:component::template::common::device_class%]", "device_class": "[%key:component::template::common::device_class%]",
@@ -454,6 +482,7 @@
"binary_sensor": "[%key:component::binary_sensor::title%]", "binary_sensor": "[%key:component::binary_sensor::title%]",
"button": "[%key:component::button::title%]", "button": "[%key:component::button::title%]",
"cover": "[%key:component::cover::title%]", "cover": "[%key:component::cover::title%]",
"device_tracker": "[%key:component::device_tracker::title%]",
"event": "[%key:component::event::title%]", "event": "[%key:component::event::title%]",
"fan": "[%key:component::fan::title%]", "fan": "[%key:component::fan::title%]",
"image": "[%key:component::image::title%]", "image": "[%key:component::image::title%]",
@@ -651,7 +680,6 @@
}, },
"title": "[%key:component::template::config::step::button::title%]" "title": "[%key:component::template::config::step::button::title%]"
}, },
"cover": { "cover": {
"data": { "data": {
"close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]",
@@ -684,6 +712,32 @@
}, },
"title": "[%key:component::template::config::step::cover::title%]" "title": "[%key:component::template::config::step::cover::title%]"
}, },
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]",
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "[%key:component::template::config::step::device_tracker::title%]"
},
"event": { "event": {
"data": { "data": {
"device_id": "[%key:common::config_flow::data::device%]", "device_id": "[%key:common::config_flow::data::device%]",

Some files were not shown because too many files have changed in this diff Show More