Compare commits

...

75 Commits

Author SHA1 Message Date
Stefan Agner 1e457600f1 Harden backup tar extraction with Python tar_filter (#172252) 2026-05-27 18:10:04 +02: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
J. Nick Koston 0a7293dbbd Bump qingping-ble to 1.1.4 and update CGPR1 test fixtures (#172292) 2026-05-26 18:21:23 -05:00
J. Nick Koston 057788d531 Add composite action to cache CI apt installs (#171735) 2026-05-27 01:17:31 +02:00
J. Nick Koston 74cb4e2448 Bump aioesphomeapi to 45.3.1 (#172287) 2026-05-26 18:10:36 -05:00
Manu 62aa79a304 Add delete profile/header picture to mastodon.update_profile action (#170930)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-27 01:09:19 +02:00
A. Gideonse da74ae1955 Add Rated Capacity to Indevolt Gen-1 devices (#171107) 2026-05-27 01:09:02 +02:00
Maikel Punie 2a4728463b Fix swallowed exceptions in velbus action handlers (#171111) 2026-05-27 01:08:42 +02:00
Amit Finkelstein 3c5bcad0e9 Update Jewish calendar holiday at candle lighting and Havdalah (#170357) 2026-05-27 01:08:22 +02:00
Adam Katic 2388353bd2 Add diagnostics support for cert_expiry integration (#170767)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 01:01:54 +02:00
Erik Montnemery 98823d6816 Use select selector for input of cast uuid allow list and CEC ignore list (#171201) 2026-05-27 00:55:49 +02:00
Thomas D cdd09f2535 Remove redundant async_on_unload calls in Qbus integration (#171214) 2026-05-27 00:55:24 +02:00
Glenn Waters 2c900c59eb ElkM1 integration: add switch_output_turn_on_for action (#170128)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 00:51:47 +02:00
Thomas55555 68757996de Add google air quality forecast service (#171142)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-27 00:48:53 +02:00
renovate[bot] 0fa3985b1d Update infrared-protocols to 5.6.1 (#172289) 2026-05-26 23:48:18 +01:00
jameson_uk a2551647b8 Add media_player platform to Alexa Devices (#165825)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-05-27 00:43:04 +02:00
Michael e19601f991 Remove deprecated APCUPSD sensors (#172280) 2026-05-27 00:17:42 +02:00
Michael bc6060f98b Remove deprecated call_in_progress binary-sensor in VoIP (#172285) 2026-05-26 23:40:50 +02:00
A. Gideonse 0e2190fb25 Add battery cycles to Indevolt (#172286) 2026-05-26 23:40:32 +02:00
Crocmagnon dd75a39e25 data grand lyon: update quality scale (#170311) 2026-05-26 23:39:48 +02:00
Heikki Henriksen 6efb3fffa3 prusalink: extract press_button_and_verify fixture for button tests (#170332)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:29:54 +02:00
epenet 4ef409f3cd Store login_token in renault config-flow (#171707) 2026-05-26 23:29:23 +02:00
Paulus Schoutsen 0842c1cdfc Add LG TV via Serial integration (#170945)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-26 23:27:01 +02:00
Joost Lekkerkerker 49c045236c Enable N806 (#171388)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-05-26 23:23:39 +02:00
AlCalzone 0b687df9f8 Migrate opensensemap to UI configuration (#171066)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-26 23:22:57 +02:00
Miko Stern ffcab49087 Improve Israel Rail departure sensor coverage (#171397)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 23:09:39 +02:00
Retha Runolfsson 06c92cd328 Add half lock for switchbot lock ultra (#168750) 2026-05-26 23:07:30 +02:00
HoffmanEl 66d4124439 Add quality scale cert expiry (#170491)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 22:42:38 +02:00
Copilot 99877d79e3 Replace duplicated ATTR_LOCATION with shared homeassistant.const import in hassio and remove unused ATTR_STATE mapping (#171334)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: agners <34061+agners@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-05-26 22:42:02 +02:00
Thomas D 978171b600 Use reported units for the Qbus integration (#171588) 2026-05-26 22:41:45 +02:00
Jonathan Segev 4bd011702e Add room priority select entity to Lyric integration (#167942) 2026-05-26 22:40:14 +02:00
Crocmagnon 64bc689bcf add ovhcloud_ai_endpoints integration (#171402) 2026-05-26 22:38:18 +02:00
405 changed files with 14625 additions and 3380 deletions
@@ -0,0 +1,52 @@
name: Cache and install APT packages
description: >-
Wraps awalsh128/cache-apt-pkgs-action with the workarounds Home Assistant CI
needs. Removes the conflicting Microsoft apt source before any apt run, and
points the dynamic linker at the host's multiarch lib subdirectories so
shared libraries that rely on update-alternatives or postinst-managed paths
(eg libblas, liblapack pulled in by ffmpeg) stay reachable since the upstream
action does not execute postinst scripts on cache restore.
inputs:
packages:
description: Space-delimited list of apt packages to install.
required: true
version:
description: Cache version. Bump to invalidate the cache.
required: false
default: "1"
execute_install_scripts:
description: >-
Pass-through to awalsh128/cache-apt-pkgs-action. Postinst scripts are not
actually cached by the upstream action, so this is largely a no-op today.
required: false
default: "false"
runs:
using: composite
steps:
- name: Remove conflicting Microsoft apt source
shell: bash
run: sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
- name: Install apt packages via cache
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: ${{ inputs.packages }}
version: ${{ inputs.version }}
execute_install_scripts: ${{ inputs.execute_install_scripts }}
- name: Refresh dynamic linker cache
shell: bash
run: |
# awalsh128/cache-apt-pkgs-action does not run postinst scripts on
# cache restore, so update-alternatives symlinks (eg the one libblas
# creates at /usr/lib/<multiarch>/libblas.so.3) are never produced.
# Add every /usr/lib/<multiarch> subdirectory that holds shared
# libraries to the ldconfig search path so the dynamic linker still
# finds them. Use dpkg-architecture to derive the host's multiarch
# tuple so this works on non-x86_64 runners too.
multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
find "/usr/lib/${multiarch}" -mindepth 2 -maxdepth 2 \
-name '*.so.*' -printf '%h\n' \
| sort -u \
| sudo tee /etc/ld.so.conf.d/zzz-cache-apt-extras.conf > /dev/null
sudo ldconfig
+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
+97 -204
View File
@@ -60,9 +60,7 @@ env:
# - 15.2 is the latest (as of 9 Feb 2023) # - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
UV_CACHE_DIR: /tmp/uv-cache UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt APT_CACHE_VERSION: 1
APT_CACHE_DIR: /home/runner/work/apt/cache
APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
SQLALCHEMY_WARN_20: 1 SQLALCHEMY_WARN_20: 1
PYTHONASYNCIODEBUG: 1 PYTHONASYNCIODEBUG: 1
HASS_CI: 1 HASS_CI: 1
@@ -86,7 +84,6 @@ jobs:
core: ${{ steps.core.outputs.changes }} core: ${{ steps.core.outputs.changes }}
integrations_glob: ${{ steps.info.outputs.integrations_glob }} integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }} integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }} python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }} requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }} mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -116,10 +113,6 @@ jobs:
# Include HA_SHORT_VERSION to force the immediate creation # Include HA_SHORT_VERSION to force the immediate creation
# of a new uv cache entry after a version bump. # of a new uv cache entry after a version bump.
echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes - name: Filter for core changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: core id: core
@@ -384,65 +377,36 @@ jobs:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: ${{ steps.generate-uv-key.outputs.full_key }} key: ${{ steps.generate-uv-key.outputs.full_key }}
restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }} restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }}
- name: Check if apt cache exists
id: cache-apt-check
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies - name: Install additional OS dependencies
if: | if: steps.cache-venv.outputs.cache-hit != 'true'
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
id: install-os-deps
timeout-minutes: 10 timeout-minutes: 10
env: uses: ./.github/actions/cache-apt-packages
APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
mkdir -p ${APT_CACHE_DIR}
mkdir -p ${APT_LIST_CACHE_DIR}
fi
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils \
libavcodec-dev \
libavdevice-dev \
libavfilter-dev \
libavformat-dev \
libavutil-dev \
libswresample-dev \
libswscale-dev \
libudev-dev
if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
sudo chmod -R 755 ${APT_CACHE_BASE}
fi
- name: Save apt cache
if: |
always()
&& steps.cache-apt-check.outputs.cache-hit != 'true'
&& steps.install-os-deps.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | packages: >-
${{ env.APT_CACHE_DIR }} bluez
${{ env.APT_LIST_CACHE_DIR }} ffmpeg
key: >- libturbojpeg
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} libxml2-utils
libavcodec-dev
libavdevice-dev
libavfilter-dev
libavformat-dev
libavutil-dev
libswresample-dev
libswscale-dev
libudev-dev
version: ${{ env.APT_CACHE_VERSION }}
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
@@ -450,8 +414,6 @@ jobs:
python -m venv venv python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -U "pip>=25.2"
uv pip install -r requirements.txt uv pip install -r requirements.txt
uv pip install -r requirements_all.txt -r requirements_test.txt uv pip install -r requirements_all.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat uv pip install -e . --config-settings editable_mode=compat
@@ -506,30 +468,16 @@ jobs:
&& github.event.inputs.mypy-only != 'true' && github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true' && github.event.inputs.audit-licenses-only != 'true'
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
libturbojpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python - name: Set up Python
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -876,32 +824,20 @@ jobs:
- info - info
- base - base
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python - name: Set up Python
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -952,33 +888,21 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }} group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1105,34 +1029,22 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libmariadb-dev-compat \
libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libmariadb-dev-compat
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1266,36 +1178,29 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \
postgresql-server-dev-14
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up PostgreSQL apt repository
run: sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
- name: Cache PostgreSQL development headers
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: postgresql-server-dev-14
version: ${{ env.APT_CACHE_VERSION }}
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -1449,33 +1354,21 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
group: ${{ fromJson(needs.info.outputs.test_groups) }} group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps: steps:
- name: Restore apt cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
fail-on-cache-miss: true
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies
timeout-minutes: 10
run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
sudo apt-get update \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR}
sudo apt-get -y install \
-o Dir::Cache=${APT_CACHE_DIR} \
-o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
bluez \
ffmpeg \
libturbojpeg \
libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install additional OS dependencies
timeout-minutes: 10
uses: ./.github/actions/cache-apt-packages
with:
packages: >-
bluez
ffmpeg
libturbojpeg
libxml2-utils
version: ${{ env.APT_CACHE_VERSION }}
execute_install_scripts: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+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:
+2
View File
@@ -337,6 +337,7 @@ homeassistant.components.led_ble.*
homeassistant.components.lektrico.* homeassistant.components.lektrico.*
homeassistant.components.letpot.* homeassistant.components.letpot.*
homeassistant.components.lg_infrared.* homeassistant.components.lg_infrared.*
homeassistant.components.lg_tv_rs232.*
homeassistant.components.libre_hardware_monitor.* homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.* homeassistant.components.lidarr.*
homeassistant.components.liebherr.* homeassistant.components.liebherr.*
@@ -428,6 +429,7 @@ homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.* homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
homeassistant.components.overseerr.* homeassistant.components.overseerr.*
homeassistant.components.ovhcloud_ai_endpoints.*
homeassistant.components.p1_monitor.* homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.* homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.* homeassistant.components.panel_custom.*
+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
+8 -2
View File
@@ -987,6 +987,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
/tests/components/lg_thinq/ @LG-ThinQ-Integration /tests/components/lg_thinq/ @LG-ThinQ-Integration
/homeassistant/components/lg_tv_rs232/ @balloob
/tests/components/lg_tv_rs232/ @balloob
/homeassistant/components/libre_hardware_monitor/ @Sab44 /homeassistant/components/libre_hardware_monitor/ @Sab44
/tests/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lichess/ @aryanhasgithub /homeassistant/components/lichess/ @aryanhasgithub
@@ -1290,6 +1292,8 @@ CLAUDE.md @home-assistant/core
/tests/components/openhome/ @bazwilliams /tests/components/openhome/ @bazwilliams
/homeassistant/components/openrgb/ @felipecrs /homeassistant/components/openrgb/ @felipecrs
/tests/components/openrgb/ @felipecrs /tests/components/openrgb/ @felipecrs
/homeassistant/components/opensensemap/ @AlCalzone
/tests/components/opensensemap/ @AlCalzone
/homeassistant/components/opensky/ @joostlek /homeassistant/components/opensky/ @joostlek
/tests/components/opensky/ @joostlek /tests/components/opensky/ @joostlek
/homeassistant/components/opentherm_gw/ @mvn23 /homeassistant/components/opentherm_gw/ @mvn23
@@ -1317,6 +1321,8 @@ CLAUDE.md @home-assistant/core
/tests/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera /homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera /tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon
/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon
/homeassistant/components/ovo_energy/ @timmo001 /homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas /homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -2048,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
+2 -4
View File
@@ -92,8 +92,7 @@ def _extract_backup(
): ):
ostf.tar.extractall( ostf.tar.extractall(
path=Path(tempdir, "extracted"), path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar), filter="tar",
filter="fully_trusted",
) )
backup_meta_file = Path(tempdir, "extracted", "backup.json") backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8")) backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -119,8 +118,7 @@ def _extract_backup(
) as istf: ) as istf:
istf.extractall( istf.extractall(
path=Path(tempdir, "homeassistant"), path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf), filter="tar",
filter="fully_trusted",
) )
if restore_content.restore_homeassistant: if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS) keep = list(KEEP_BACKUPS)
+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
@@ -17,6 +17,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.EVENT, Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.NOTIFY, Platform.NOTIFY,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
@@ -40,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
await coordinator.sync_history_state() await coordinator.sync_history_state()
await coordinator.sync_media_state()
async def _on_http2_reauth_required() -> None: async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass) entry.async_start_reauth(hass)
@@ -8,7 +8,12 @@ from aioamazondevices.exceptions import (
CannotConnect, CannotConnect,
CannotRetrieveData, CannotRetrieveData,
) )
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord from aioamazondevices.structures import (
AmazonDevice,
AmazonMediaState,
AmazonVocalRecord,
AmazonVolumeState,
)
from aiohttp import ClientSession from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -74,10 +79,17 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
} }
self._vocal_records: dict[str, AmazonVocalRecord] = {} self._vocal_records: dict[str, AmazonVocalRecord] = {}
self.api.on_history_event.append(self.history_state_event_handler) self.api.on_history_event.append(self.history_state_event_handler)
self.api.on_history_event.freeze() self.api.on_history_event.freeze()
self._volume_states: dict[str, AmazonVolumeState] = {}
self.api.on_volume_state_event.append(self.volume_state_event_handler)
self.api.on_volume_state_event.freeze()
self._media_states: dict[str, AmazonMediaState] = {}
self.api.on_media_state_event.append(self.media_state_event_handler)
self.api.on_media_state_event.freeze()
async def _async_update_data(self) -> dict[str, AmazonDevice]: async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data.""" """Update device data."""
try: try:
@@ -189,3 +201,31 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
def vocal_records(self) -> dict[str, AmazonVocalRecord]: def vocal_records(self) -> dict[str, AmazonVocalRecord]:
"""Vocal records of devices.""" """Vocal records of devices."""
return self._vocal_records return self._vocal_records
async def sync_media_state(self) -> None:
"""Sync media state."""
await self.api.sync_media_state()
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
) -> None:
"""Handle pushed media state changed events."""
self._media_states = media_state
self.async_update_listeners()
@property
def media_states(self) -> dict[str, AmazonMediaState]:
"""Media state of devices."""
return self._media_states
async def volume_state_event_handler(
self, volume_states: dict[str, AmazonVolumeState]
) -> None:
"""Handle pushed volume change events."""
self._volume_states = volume_states
self.async_update_listeners()
@property
def volume_states(self) -> dict[str, AmazonVolumeState]:
"""Volumes of devices."""
return self._volume_states
@@ -0,0 +1,294 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from aioamazondevices.structures import (
AmazonMediaControls,
AmazonMediaState,
AmazonVolumeState,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
STANDARD_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices media player entities from a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
"""Add entities for newly discovered devices."""
new_entities: list[AlexaDevicesMediaPlayer] = []
for serial_num, device in coordinator.data.items():
if serial_num in known_devices or not device.media_player_supported:
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
)
if new_entities:
async_add_entities(new_entities)
remove_listener = coordinator.async_add_listener(_check_device)
entry.async_on_unload(remove_listener)
_check_device()
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
super().__init__(coordinator, serial_num, description)
@property
def media_state(self) -> AmazonMediaState | None:
"""Return the media state relating to device."""
if not self.coordinator or not self.coordinator.media_states:
return None
return self.coordinator.media_states.get(self._serial_num)
@property
def volume_state(self) -> AmazonVolumeState | None:
"""Volume settings for device."""
if not self.coordinator or not self.coordinator.volume_states:
return None
return self.coordinator.volume_states.get(self._serial_num)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return dynamically supported features based on current media."""
features = STANDARD_SUPPORTED_FEATURES
if self.media_state is None:
return features
if self.media_state.pause_enabled:
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
if self.media_state.next_enabled:
features |= MediaPlayerEntityFeature.NEXT_TRACK
if self.media_state.previous_enabled:
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
return features
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the player."""
if not self.media_state:
return MediaPlayerState.IDLE
if self.media_state.player_state == "PLAYING":
return MediaPlayerState.PLAYING
if self.media_state.player_state == "PAUSED":
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE
@property
def volume_level(self) -> float | None:
"""Return the volume level (0.0 to 1.0)."""
if not self.volume_state or self.volume_state.volume is None:
return None
return self.volume_state.volume / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state:
return None
return self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
"""Track title."""
if not self.media_state:
return None
return self.media_state.now_playing_title
@property
def media_artist(self) -> str | None:
"""Artist name."""
if not self.media_state:
return None
return self.media_state.now_playing_line1
@property
def media_album_name(self) -> str | None:
"""Album name."""
if not self.media_state:
return None
return self.media_state.now_playing_line2
@property
def media_image_url(self) -> str | None:
"""Album art URL."""
if not self.media_state:
return None
return self.media_state.now_playing_url
@property
def media_duration(self) -> int | None:
"""Duration in seconds."""
if not self.media_state:
return None
return self.media_state.media_length
@property
def media_position(self) -> int | None:
"""Current playback position in seconds."""
if not self.media_state:
return None
return self.media_state.media_position
@property
def media_position_updated_at(self) -> datetime | None:
"""When media_position was last updated — HA uses this to interpolate the progress bar."""
if not self.media_state:
return None
return self.media_state.media_position_updated_at
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
return MediaType.MUSIC
return None
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
announce: bool | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
"Setting volume for %s to %s%%",
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
device_volume = round(volume * 100)
await self.async_set_device_volume(device_volume)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or un-mute the volume."""
# Whilst you can mute a device by asking it there appears to be
# no way to do this programmatically so set volume to 0
if not self.volume_state or self.volume_state.volume is None:
return
if mute:
self._prev_volume = self.volume_state.volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_media_command(AmazonMediaControls.Stop)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_media_command(AmazonMediaControls.Pause)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_media_command(AmazonMediaControls.Play)
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_media_command(AmazonMediaControls.Next)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_media_command(AmazonMediaControls.Previous)
@@ -230,13 +230,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
if entry.version == 2 and entry.minor_version == 3: if entry.version == 2 and entry.minor_version == 3:
# Remove Temperature parameter # Remove Temperature parameter
CONF_TEMPERATURE = "temperature" temperature_key = "temperature"
for subentry in entry.subentries.values(): for subentry in entry.subentries.values():
data = subentry.data.copy() data = subentry.data.copy()
if CONF_TEMPERATURE not in data: if temperature_key not in data:
continue continue
data.pop(CONF_TEMPERATURE, None) data.pop(temperature_key, None)
hass.config_entries.async_update_subentry(entry, subentry, data=data) hass.config_entries.async_update_subentry(entry, subentry, data=data)
hass.config_entries.async_update_entry(entry, minor_version=4) hass.config_entries.async_update_entry(entry, minor_version=4)
-24
View File
@@ -7,27 +7,3 @@ CONNECTION_TIMEOUT: int = 10
# Field name of last self test retrieved from apcupsd. # Field name of last self test retrieved from apcupsd.
LAST_S_TEST: Final = "laststest" LAST_S_TEST: Final = "laststest"
# Mapping of deprecated sensor keys (as reported by apcupsd,
# lower-cased) to their deprecation
# repair issue translation keys.
DEPRECATED_SENSORS: Final = {
"apc": "apc_deprecated",
"end apc": "date_deprecated",
"date": "date_deprecated",
"apcmodel": "available_via_device_info",
"model": "available_via_device_info",
"firmware": "available_via_device_info",
"version": "available_via_device_info",
"upsname": "available_via_device_info",
"serialno": "available_via_device_info",
}
AVAILABLE_VIA_DEVICE_ATTR: Final = {
"apcmodel": "model",
"model": "model",
"firmware": "hw_version",
"version": "sw_version",
"upsname": "name",
"serialno": "serial_number",
}
+19 -121
View File
@@ -1,11 +1,10 @@
"""Support for APCUPSd sensors.""" """Support for APCUPSd sensors."""
import logging import logging
from typing import Final
import dateutil import dateutil
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@@ -24,11 +23,9 @@ from homeassistant.const import (
UnitOfTime, UnitOfTime,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.issue_registry as ir
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST from .const import LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity from .entity import APCUPSdEntity
@@ -36,6 +33,20 @@ PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# List of useless sensors to ignore, since they are either provided in device
# information, or not useful at all
IGNORED_SENSORS: Final = {
"apc",
"end apc",
"date",
"apcmodel",
"model",
"firmware",
"version",
"upsname",
"serialno",
}
SENSORS: dict[str, SensorEntityDescription] = { SENSORS: dict[str, SensorEntityDescription] = {
"alarmdel": SensorEntityDescription( "alarmdel": SensorEntityDescription(
key="alarmdel", key="alarmdel",
@@ -49,18 +60,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
"apc": SensorEntityDescription(
key="apc",
translation_key="apc_status",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"apcmodel": SensorEntityDescription(
key="apcmodel",
translation_key="apc_model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"badbatts": SensorEntityDescription( "badbatts": SensorEntityDescription(
key="badbatts", key="badbatts",
translation_key="bad_batteries", translation_key="bad_batteries",
@@ -100,12 +99,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.DURATION, device_class=SensorDeviceClass.DURATION,
), ),
"date": SensorEntityDescription(
key="date",
translation_key="date",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"dipsw": SensorEntityDescription( "dipsw": SensorEntityDescription(
key="dipsw", key="dipsw",
translation_key="dip_switch_settings", translation_key="dip_switch_settings",
@@ -132,23 +125,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="wake_delay", translation_key="wake_delay",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
"end apc": SensorEntityDescription(
key="end apc",
translation_key="date_and_time",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"extbatts": SensorEntityDescription( "extbatts": SensorEntityDescription(
key="extbatts", key="extbatts",
translation_key="external_batteries", translation_key="external_batteries",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
"firmware": SensorEntityDescription(
key="firmware",
translation_key="firmware_version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"hitrans": SensorEntityDescription( "hitrans": SensorEntityDescription(
key="hitrans", key="hitrans",
translation_key="transfer_high", translation_key="transfer_high",
@@ -264,12 +245,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="min_time", translation_key="min_time",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
"model": SensorEntityDescription(
key="model",
translation_key="model",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"nombattv": SensorEntityDescription( "nombattv": SensorEntityDescription(
key="nombattv", key="nombattv",
translation_key="battery_nominal_voltage", translation_key="battery_nominal_voltage",
@@ -358,12 +333,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
"serialno": SensorEntityDescription(
key="serialno",
translation_key="serial_number",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"starttime": SensorEntityDescription( "starttime": SensorEntityDescription(
key="starttime", key="starttime",
translation_key="startup_time", translation_key="startup_time",
@@ -404,18 +373,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="ups_mode", translation_key="ups_mode",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
"upsname": SensorEntityDescription(
key="upsname",
translation_key="ups_name",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"version": SensorEntityDescription(
key="version",
translation_key="version",
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbat": SensorEntityDescription( "xoffbat": SensorEntityDescription(
key="xoffbat", key="xoffbat",
translation_key="transfer_from_battery", translation_key="transfer_from_battery",
@@ -481,9 +438,10 @@ async def async_setup_entry(
# as unknown initially. # as unknown initially.
# #
# We also sort the resources to ensure the order of entities # We also sort the resources to ensure the order of entities
# created is deterministic since "APCMODEL" and "MODEL" # created is deterministic
# resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}): for resource in sorted(available_resources | {LAST_S_TEST}):
if resource in IGNORED_SENSORS:
continue
if resource not in SENSORS: if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper()) _LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue continue
@@ -561,63 +519,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value, inferred_unit = infer_unit(data) self._attr_native_value, inferred_unit = infer_unit(data)
if not self.native_unit_of_measurement: if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit self._attr_native_unit_of_measurement = inferred_unit
async def async_added_to_hass(self) -> None:
"""Handle when entity is added to Home Assistant.
If this is a deprecated sensor entity, create a repair issue to guide
the user to disable it.
"""
await super().async_added_to_hass()
if not self.enabled:
return
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
if not reason:
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
if not automations and not scripts:
return
entity_registry = er.async_get(self.hass)
items = [
f"- [{entry.name or entry.original_name or entity_id}]"
f"(/config/{integration}/edit/"
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (entry := entity_registry.async_get(entity_id))
]
placeholders = {
"entity_name": str(self.name or self.entity_id),
"entity_id": self.entity_id,
"items": "\n".join(items),
}
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
placeholders["available_via_device_attr"] = via_attr
if device_entry := self.device_entry:
placeholders["device_id"] = device_entry.id
ir.async_create_issue(
self.hass,
DOMAIN,
f"{reason}_{self.entity_id}",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=reason,
translation_placeholders=placeholders,
)
async def async_will_remove_from_hass(self) -> None:
"""Handle when entity will be removed from Home Assistant."""
await super().async_will_remove_from_hass()
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
@@ -241,19 +241,5 @@
"cannot_connect": { "cannot_connect": {
"message": "Cannot connect to APC UPS Daemon." "message": "Cannot connect to APC UPS Daemon."
} }
},
"issues": {
"apc_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"available_via_device_info": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"date_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
}
} }
} }
@@ -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
+14 -14
View File
@@ -49,6 +49,20 @@ SENSORS_TYPE_COUNT = "sensors_count"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_ENTITY_MIGRATION_ID = {
"sensor_connected_device": "Devices Connected",
"sensor_rx_bytes": "Download",
"sensor_tx_bytes": "Upload",
"sensor_rx_rates": "Download Speed",
"sensor_tx_rates": "Upload Speed",
"sensor_load_avg1": "Load Avg (1m)",
"sensor_load_avg5": "Load Avg (5m)",
"sensor_load_avg15": "Load Avg (15m)",
"2.4GHz": "2.4GHz Temperature",
"5.0GHz": "5GHz Temperature",
"CPU": "CPU Temperature",
}
class AsusWrtSensorDataHandler: class AsusWrtSensorDataHandler:
"""Data handler for AsusWrt sensor.""" """Data handler for AsusWrt sensor."""
@@ -187,20 +201,6 @@ class AsusWrtRouter:
def _migrate_entities_unique_id(self) -> None: def _migrate_entities_unique_id(self) -> None:
"""Migrate router entities to new unique id format.""" """Migrate router entities to new unique id format."""
_ENTITY_MIGRATION_ID = {
"sensor_connected_device": "Devices Connected",
"sensor_rx_bytes": "Download",
"sensor_tx_bytes": "Upload",
"sensor_rx_rates": "Download Speed",
"sensor_tx_rates": "Upload Speed",
"sensor_load_avg1": "Load Avg (1m)",
"sensor_load_avg5": "Load Avg (5m)",
"sensor_load_avg15": "Load Avg (15m)",
"2.4GHz": "2.4GHz Temperature",
"5.0GHz": "5GHz Temperature",
"CPU": "CPU Temperature",
}
entity_reg = er.async_get(self.hass) entity_reg = er.async_get(self.hass)
router_entries = er.async_entries_for_config_entry( router_entries = er.async_entries_for_config_entry(
entity_reg, self._entry.entry_id entity_reg, self._entry.entry_id
@@ -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"
] ]
} }
+14 -21
View File
@@ -32,8 +32,16 @@ OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
vol.Required(CONF_MORE_OPTIONS): section( vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema( vol.Schema(
{ {
vol.Optional(CONF_UUID): str, vol.Optional(CONF_UUID): SelectSelector(
vol.Optional(CONF_IGNORE_CEC): str, SelectSelectorConfig(
custom_value=True, options=[], multiple=True
),
),
vol.Optional(CONF_IGNORE_CEC): SelectSelector(
SelectSelectorConfig(
custom_value=True, options=[], multiple=True
),
),
} }
), ),
SectionConfig(collapsed=True), SectionConfig(collapsed=True),
@@ -109,13 +117,11 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the Google Cast options.""" """Manage the Google Cast options."""
if user_input is not None: if user_input is not None:
ignore_cec = _string_to_list( ignore_cec = _trim_items(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "") user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, [])
) )
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
wanted_uuid = _string_to_list( wanted_uuid = _trim_items(user_input[CONF_MORE_OPTIONS].get(CONF_UUID, []))
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
)
updated_config = dict(self.config_entry.data) updated_config = dict(self.config_entry.data)
updated_config[CONF_IGNORE_CEC] = ignore_cec updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts updated_config[CONF_KNOWN_HOSTS] = known_hosts
@@ -132,9 +138,7 @@ class CastOptionsFlowHandler(OptionsFlow):
for key in (CONF_UUID, CONF_IGNORE_CEC): for key in (CONF_UUID, CONF_IGNORE_CEC):
if key not in self.config_entry.data: if key not in self.config_entry.data:
continue continue
suggested[CONF_MORE_OPTIONS][key] = _list_to_string( suggested[CONF_MORE_OPTIONS][key] = self.config_entry.data[key]
self.config_entry.data[key]
)
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
@@ -143,16 +147,5 @@ class CastOptionsFlowHandler(OptionsFlow):
) )
def _list_to_string(items: list[str]) -> str:
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string
def _string_to_list(string: str) -> list[str]:
return [x.strip() for x in string.split(",") if x.strip()]
def _trim_items(items: list[str]) -> list[str]: def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()] return [x.strip() for x in items if x.strip()]
@@ -0,0 +1,57 @@
"""Diagnostics for the cert_expiry integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .coordinator import CertExpiryConfigEntry
TO_REDACT = {CONF_HOST, "name", "title", "unique_id"}
async def async_get_config_entry_diagnostics(
_hass: HomeAssistant,
entry: CertExpiryConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
entry_diagnostics = entry.as_dict()
coordinator = getattr(entry, "runtime_data", None)
coordinator_diagnostics: dict[str, Any] = {
"host": None,
"port": None,
"name": None,
"expiry_datetime": None,
"is_cert_valid": None,
"cert_error": None,
"last_update_success": None,
}
if coordinator is not None:
expiry = coordinator.data.isoformat() if coordinator.data else None
cert_error = (
(
f"{type(coordinator.cert_error).__module__}."
f"{type(coordinator.cert_error).__qualname__}"
)
if coordinator.cert_error
else None
)
coordinator_diagnostics = {
"host": coordinator.host,
"port": coordinator.port,
"name": coordinator.name,
"expiry_datetime": expiry,
"is_cert_valid": coordinator.is_cert_valid,
"cert_error": cert_error,
"last_update_success": coordinator.last_update_success,
}
return {
"entry": async_redact_data(entry_diagnostics, TO_REDACT),
"coordinator": async_redact_data(coordinator_diagnostics, TO_REDACT),
}
@@ -0,0 +1,81 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
config-flow: done
dependency-transparency:
status: exempt
comment: Integration has no external library dependencies.
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Config flow only collects host/port; the integration does not authenticate.
test-coverage:
status: todo
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
entity-category:
status: todo
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt
comment: Integration supports a single device per config entry.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -16,6 +16,10 @@
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
}, },
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Reconfigure the certificate to test" "title": "Reconfigure the certificate to test"
}, },
"user": { "user": {
@@ -24,6 +28,10 @@
"name": "The name of the certificate", "name": "The name of the certificate",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
}, },
"data_description": {
"host": "The hostname or IP address of the server to monitor.",
"port": "The port to connect to on the server."
},
"title": "Define the certificate to test" "title": "Define the certificate to test"
} }
} }
+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
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon", "documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "silver", "quality_scale": "platinum",
"requirements": ["data-grand-lyon-ha==0.7.0"] "requirements": ["data-grand-lyon-ha==0.7.0"]
} }
@@ -49,13 +49,15 @@ rules:
status: exempt status: exempt
comment: This is a service integration; there are no discoverable devices. comment: This is a service integration; there are no discoverable devices.
docs-data-update: done docs-data-update: done
docs-examples: todo docs-examples: done
docs-known-limitations: done docs-known-limitations: done
docs-supported-devices: done docs-supported-devices: done
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: done dynamic-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
@@ -66,7 +68,9 @@ rules:
repair-issues: repair-issues:
status: exempt status: exempt
comment: no known use cases for repair issues or flows, yet comment: no known use cases for repair issues or flows, yet
stale-devices: done stale-devices:
status: exempt
comment: This is a service integration; devices are added and removed manually by the user.
# Platinum # Platinum
async-dependency: done async-dependency: done
@@ -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"
} }
} }
} }
+5 -5
View File
@@ -41,7 +41,7 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {} self.stations = {}
for station in stations: for station in stations:
label = station["label"] label = station["label"]
rloId = station["RLOIid"] rlo_id = station["RLOIid"]
# API annoyingly sometimes returns a list and some times returns a string # API annoyingly sometimes returns a list and some times returns a string
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level'] # E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
@@ -50,11 +50,11 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
# Similar for RLOIid # Similar for RLOIid
# E.g. 0018 has an RLOIid of ['10427', '9154'] # E.g. 0018 has an RLOIid of ['10427', '9154']
if isinstance(rloId, list): if isinstance(rlo_id, list):
rloId = rloId[-1] rlo_id = rlo_id[-1]
fullName = label + " - " + rloId full_name = label + " - " + rlo_id
self.stations[fullName] = station["stationReference"] self.stations[full_name] = station["stationReference"]
if not self.stations: if not self.stations:
return self.async_abort(reason="no_stations") return self.async_abort(reason="no_stations")
+1
View File
@@ -40,6 +40,7 @@ ELK_ELEMENTS = {
EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed" EVENT_ELKM1_KEYPAD_KEY_PRESSED = "elkm1.keypad_key_pressed"
ATTR_DURATION = "duration"
ATTR_KEYPAD_ID = "keypad_id" ATTR_KEYPAD_ID = "keypad_id"
ATTR_KEY = "key" ATTR_KEY = "key"
ATTR_KEY_NAME = "key_name" ATTR_KEY_NAME = "key_name"
@@ -48,6 +48,9 @@
}, },
"speak_word": { "speak_word": {
"service": "mdi:message-minus" "service": "mdi:message-minus"
},
"switch_output_turn_on_for": {
"service": "mdi:timer"
} }
} }
} }
@@ -161,3 +161,15 @@ sensor_zone_trigger:
entity: entity:
integration: elkm1 integration: elkm1
domain: sensor domain: sensor
switch_output_turn_on_for:
target:
entity:
integration: elkm1
domain: switch
fields:
duration:
example: 42
required: true
selector:
duration:
@@ -210,6 +210,16 @@
} }
}, },
"name": "Speak word" "name": "Speak word"
},
"switch_output_turn_on_for": {
"description": "Turns on an output for a specified length of time.",
"fields": {
"duration": {
"description": "Length of time to turn the output on for.",
"name": "Duration"
}
},
"name": "Switch output turn on for"
} }
} }
} }
+34 -1
View File
@@ -1,5 +1,7 @@
"""Support for control of ElkM1 outputs (relays).""" """Support for control of ElkM1 outputs (relays)."""
from datetime import timedelta
from math import ceil
from typing import Any from typing import Any
from elkm1_lib.const import ThermostatMode, ThermostatSetting from elkm1_lib.const import ThermostatMode, ThermostatSetting
@@ -7,15 +9,29 @@ from elkm1_lib.elements import Element
from elkm1_lib.elk import Elk from elkm1_lib.elk import Elk
from elkm1_lib.outputs import Output from elkm1_lib.outputs import Output
from elkm1_lib.thermostats import Thermostat from elkm1_lib.thermostats import Thermostat
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry from . import ElkM1ConfigEntry
from .const import ATTR_DURATION, DOMAIN
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .models import ELKM1Data from .models import ELKM1Data
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR = "switch_output_turn_on_for"
ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA: VolDictType = {
vol.Required(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(seconds=1), max=timedelta(seconds=65535)),
),
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@@ -32,6 +48,15 @@ async def async_setup_entry(
) )
async_add_entities(entities) async_add_entities(entities)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SWITCH_OUTPUT_TURN_ON_FOR,
entity_domain=SWITCH_DOMAIN,
schema=ELK_OUTPUT_TURN_ON_FOR_SERVICE_SCHEMA,
func="async_switch_output_turn_on_for",
)
class ElkOutput(ElkAttachedEntity, SwitchEntity): class ElkOutput(ElkAttachedEntity, SwitchEntity):
"""Elk output as switch.""" """Elk output as switch."""
@@ -51,6 +76,10 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity):
"""Turn off the output.""" """Turn off the output."""
self._element.turn_off() self._element.turn_off()
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
"""Turn on an output for specified length of time."""
self._element.turn_on(ceil(duration.total_seconds()))
class ElkThermostatEMHeat(ElkEntity, SwitchEntity): class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
"""Elk Thermostat emergency heat as switch.""" """Elk Thermostat emergency heat as switch."""
@@ -79,3 +108,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the output.""" """Turn off the output."""
self._elk_set(ThermostatMode.EMERGENCY_HEAT) self._elk_set(ThermostatMode.EMERGENCY_HEAT)
async def async_switch_output_turn_on_for(self, duration: timedelta) -> None:
"""Turn on an output for specified length of time: not supported for thermostat."""
raise HomeAssistantError("supported only on ElkM1 output switch entities")
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==45.2.2", "aioesphomeapi==45.3.1",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1" "bleak-esphome==3.9.1"
], ],
+3 -3
View File
@@ -124,11 +124,11 @@ async def async_setup_entry(
for camera in coordinator.data: for camera in coordinator.data:
device_category = coordinator.data[camera].get("device_category") device_category = coordinator.data[camera].get("device_category")
supportExt = coordinator.data[camera].get("supportExt") support_ext = coordinator.data[camera].get("supportExt")
if ( if (
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
and supportExt and support_ext
and str(SupportExt.SupportBatteryManage.value) in supportExt and str(SupportExt.SupportBatteryManage.value) in support_ext
): ):
entities.append( entities.append(
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE) EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
+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
@@ -7,19 +7,30 @@ from google_air_quality_api.auth import Auth
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_REFERRER from .const import CONF_REFERRER, DOMAIN
from .coordinator import ( from .coordinator import (
GoogleAirQualityConfigEntry, GoogleAirQualityConfigEntry,
GoogleAirQualityRuntimeData, GoogleAirQualityRuntimeData,
GoogleAirQualityUpdateCoordinator, GoogleAirQualityUpdateCoordinator,
) )
from .services import async_setup_services
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.SENSOR, Platform.SENSOR,
] ]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google Air Quality integration."""
async_setup_services(hass)
return True
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
@@ -11,5 +11,10 @@
"default": "mdi:molecule" "default": "mdi:molecule"
} }
} }
},
"services": {
"get_forecast": {
"service": "mdi:clock-end"
}
} }
} }
@@ -0,0 +1,107 @@
"""Services for the Google Air Quality integration."""
from datetime import timedelta
from typing import Final, cast
from google_air_quality_api.exceptions import GoogleAirQualityApiError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, selector
from .const import DOMAIN
from .coordinator import GoogleAirQualityConfigEntry
ATTR_HOURS: Final = "hours"
FORECAST_HOURS_MAX: Final = 96
SERVICE_GET_FORECAST: Final = "get_forecast"
SERVICE_GET_FORECAST_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector({"integration": DOMAIN}),
vol.Required(ATTR_HOURS): vol.All(
vol.Coerce(int), vol.Range(min=1, max=FORECAST_HOURS_MAX)
),
}
)
def _get_config_entry_and_subentry_id(
hass: HomeAssistant, device_id: str
) -> tuple[GoogleAirQualityConfigEntry, str]:
"""Get the config entry and subentry from a selected location device."""
device = dr.async_get(hass).async_get(device_id)
if device is not None:
for entry_id, subentry_ids in device.config_entries_subentries.items():
config_entry: ConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if config_entry is None or config_entry.domain != DOMAIN:
continue
gaq_config_entry = cast(GoogleAirQualityConfigEntry, config_entry)
for subentry_id in subentry_ids:
if (
subentry_id is not None
and subentry_id
in gaq_config_entry.runtime_data.subentries_runtime_data
):
return gaq_config_entry, subentry_id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
)
async def _async_get_forecast(call: ServiceCall) -> ServiceResponse:
"""Fetch the air quality forecast for a configured location."""
config_entry, subentry_id = _get_config_entry_and_subentry_id(
call.hass, call.data[ATTR_DEVICE_ID]
)
coordinator = config_entry.runtime_data.subentries_runtime_data[subentry_id]
try:
forecast = await config_entry.runtime_data.api.async_get_forecast(
coordinator.lat,
coordinator.long,
timedelta(hours=call.data[ATTR_HOURS]),
)
except GoogleAirQualityApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_fetch",
) from err
return cast(
ServiceResponse,
{
"forecast_time": forecast.hourly_forecasts[0].date_time,
"indexes": forecast.hourly_forecasts[0].indexes,
"pollutants": forecast.hourly_forecasts[0].pollutants,
},
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_FORECAST,
_async_get_forecast,
schema=SERVICE_GET_FORECAST_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -0,0 +1,15 @@
get_forecast:
fields:
device_id:
required: true
selector:
device:
integration: google_air_quality
hours:
required: true
selector:
number:
min: 1
max: 96
step: 1
mode: box
@@ -270,8 +270,27 @@
} }
}, },
"exceptions": { "exceptions": {
"device_not_found": {
"message": "Location not found."
},
"unable_to_fetch": { "unable_to_fetch": {
"message": "[%key:component::google_air_quality::common::unable_to_fetch%]" "message": "[%key:component::google_air_quality::common::unable_to_fetch%]"
} }
},
"services": {
"get_forecast": {
"description": "Get an air quality forecast for a configured location.",
"fields": {
"device_id": {
"description": "The location to fetch the forecast for.",
"name": "Location"
},
"hours": {
"description": "How many hours into the future to forecast.",
"name": "Hours"
}
},
"name": "Get forecast"
}
} }
} }
+5 -5
View File
@@ -117,13 +117,13 @@ class DriveClient:
"""Get storage quota of the current user.""" """Get storage quota of the current user."""
res = await self._api.get_user(params={"fields": "storageQuota"}) res = await self._api.get_user(params={"fields": "storageQuota"})
storageQuota = res["storageQuota"] storage_quota = res["storageQuota"]
limit = storageQuota.get("limit") limit = storage_quota.get("limit")
return StorageQuotaData( return StorageQuotaData(
limit=int(limit) if limit is not None else None, limit=int(limit) if limit is not None else None,
usage=int(storageQuota.get("usage", 0)), usage=int(storage_quota.get("usage", 0)),
usage_in_drive=int(storageQuota.get("usageInDrive", 0)), usage_in_drive=int(storage_quota.get("usageInDrive", 0)),
usage_in_trash=int(storageQuota.get("usageInTrash", 0)), usage_in_trash=int(storage_quota.get("usageInTrash", 0)),
) )
async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]: async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
@@ -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"
@@ -580,17 +580,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if tool_results: if tool_results:
messages.append(_create_google_tool_response_content(tool_results)) messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = self.create_generate_content_config() generate_content_config = self.create_generate_content_config()
generateContentConfig.tools = tools or None generate_content_config.tools = tools or None
generateContentConfig.system_instruction = ( generate_content_config.system_instruction = (
prompt if supports_system_instruction else None prompt if supports_system_instruction else None
) )
generateContentConfig.automatic_function_calling = ( generate_content_config.automatic_function_calling = (
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
) )
if structure: if structure:
generateContentConfig.response_mime_type = "application/json" generate_content_config.response_mime_type = "application/json"
generateContentConfig.response_schema = _format_schema( generate_content_config.response_schema = _format_schema(
convert( convert(
structure, structure,
custom_serializer=( custom_serializer=(
@@ -608,7 +608,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
*messages, *messages,
] ]
chat = self._genai_client.aio.chats.create( chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig model=model_name, history=messages, config=generate_content_config
) )
user_message = chat_log.content[-1] user_message = chat_log.content[-1]
assert isinstance(user_message, conversation.UserContent) assert isinstance(user_message, conversation.UserContent)
@@ -313,7 +313,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
) )
coordinator = entry.runtime_data coordinator = entry.runtime_data
FUNC_MAP = { func_map = {
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
@@ -322,7 +322,7 @@ async def _manage_quests(call: ServiceCall) -> ServiceResponse:
SERVICE_START_QUEST: coordinator.habitica.start_quest, SERVICE_START_QUEST: coordinator.habitica.start_quest,
} }
func = FUNC_MAP[call.service] func = func_map[call.service]
try: try:
response = await func() response = await func()
-17
View File
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
ATTR_VERSION = "version" ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest" ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent" ATTR_CPU_PERCENT = "cpu_percent"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent" ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug" ATTR_SLUG = "slug"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_STATE = "state"
ATTR_STARTED = "started" ATTR_STARTED = "started"
ATTR_URL = "url" ATTR_URL = "url"
ATTR_REPOSITORY = "repository" ATTR_REPOSITORY = "repository"
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor" SUPERVISOR_CONTAINER = "hassio_supervisor"
CONTAINER_STATS = "stats" CONTAINER_STATS = "stats"
CONTAINER_INFO = "info"
# This is a mapping of which endpoint the key in the addon data
# is obtained from so we know which endpoint to update when the
# coordinator polls for updates.
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
ATTR_VERSION_LATEST: {CONTAINER_INFO},
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
ATTR_CPU_PERCENT: {CONTAINER_STATS},
ATTR_VERSION: {CONTAINER_INFO},
ATTR_STATE: {CONTAINER_INFO},
}
REQUEST_REFRESH_DELAY = 10 REQUEST_REFRESH_DELAY = 10
HELP_URLS = { HELP_URLS = {
+1 -2
View File
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
from homeassistant.core import ( from homeassistant.core import (
HomeAssistant, HomeAssistant,
ServiceCall, ServiceCall,
@@ -43,7 +43,6 @@ from .const import (
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT, ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD, ATTR_PASSWORD,
ATTR_SLUG, ATTR_SLUG,
DOMAIN, DOMAIN,
@@ -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": {
+5 -6
View File
@@ -30,6 +30,11 @@ OPEN_CLOSE_ATTRIBUTES = [
AttributeType.UP_DOWN, AttributeType.UP_DOWN,
] ]
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
@@ -69,12 +74,6 @@ def get_cover_features(
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile.""" """Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
return COVER_DEVICE_PROFILES.get(node.profile) return COVER_DEVICE_PROFILES.get(node.profile)
@@ -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
+8 -1
View File
@@ -32,6 +32,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
IndevoltSystem.BYPASS_POWER, IndevoltSystem.BYPASS_POWER,
IndevoltSystem.BYPASS_INPUT_ENERGY, IndevoltSystem.BYPASS_INPUT_ENERGY,
IndevoltBattery.RATED_CAPACITY,
IndevoltBattery.DAILY_CHARGING_ENERGY, IndevoltBattery.DAILY_CHARGING_ENERGY,
IndevoltBattery.DAILY_DISCHARGING_ENERGY, IndevoltBattery.DAILY_DISCHARGING_ENERGY,
IndevoltBattery.TOTAL_CHARGING_ENERGY, IndevoltBattery.TOTAL_CHARGING_ENERGY,
@@ -78,7 +79,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltSolar.DC_INPUT_POWER_2, IndevoltSolar.DC_INPUT_POWER_2,
IndevoltSolar.DC_INPUT_POWER_3, IndevoltSolar.DC_INPUT_POWER_3,
IndevoltSolar.DC_INPUT_POWER_4, IndevoltSolar.DC_INPUT_POWER_4,
IndevoltBattery.RATED_CAPACITY_GEN2, IndevoltBattery.RATED_CAPACITY,
IndevoltSystem.BYPASS_POWER, IndevoltSystem.BYPASS_POWER,
IndevoltSystem.TOTAL_OUTPUT_ENERGY, IndevoltSystem.TOTAL_OUTPUT_ENERGY,
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
@@ -134,6 +135,12 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
IndevoltBattery.PACK_3_CURRENT, IndevoltBattery.PACK_3_CURRENT,
IndevoltBattery.PACK_4_CURRENT, IndevoltBattery.PACK_4_CURRENT,
IndevoltBattery.PACK_5_CURRENT, IndevoltBattery.PACK_5_CURRENT,
IndevoltBattery.MAIN_CYCLES,
IndevoltBattery.PACK_1_CYCLES,
IndevoltBattery.PACK_2_CYCLES,
IndevoltBattery.PACK_3_CYCLES,
IndevoltBattery.PACK_4_CYCLES,
IndevoltBattery.PACK_5_CYCLES,
IndevoltConfig.READ_BYPASS, IndevoltConfig.READ_BYPASS,
IndevoltConfig.READ_GRID_CHARGING, IndevoltConfig.READ_GRID_CHARGING,
IndevoltConfig.READ_LIGHT, IndevoltConfig.READ_LIGHT,
@@ -1,6 +1,7 @@
"""Home Assistant integration for Indevolt device.""" """Home Assistant integration for Indevolt device."""
from datetime import timedelta from datetime import timedelta
import itertools
import logging import logging
from typing import Any, Final from typing import Any, Final
@@ -29,6 +30,7 @@ from .const import (
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_BATCH_SIZE: Final = 50
SCAN_INTERVAL: Final = 30 SCAN_INTERVAL: Final = 30
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator] type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
@@ -86,10 +88,13 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Fetch raw JSON data from the device.""" """Fetch raw JSON data from the device."""
data: dict[str, Any] = {}
sensor_keys = SENSOR_KEYS[self.generation] sensor_keys = SENSOR_KEYS[self.generation]
try: try:
return await self.api.fetch_data(sensor_keys) for chunk in itertools.batched(sensor_keys, SCAN_BATCH_SIZE, strict=False):
data.update(await self.api.fetch_data(list(chunk)))
except (ClientError, OSError) as err: except (ClientError, OSError) as err:
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@@ -97,6 +102,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_placeholders={"error": str(err)}, translation_placeholders={"error": str(err)},
) from err ) from err
else:
return data
async def async_push_data(self, sensor_key: str, value: Any) -> bool: async def async_push_data(self, sensor_key: str, value: Any) -> bool:
"""Push/write data values to given key on the device.""" """Push/write data values to given key on the device."""
return await self.api.set_data(sensor_key, value) return await self.api.set_data(sensor_key, value)
+57 -5
View File
@@ -73,12 +73,10 @@ SENSORS: Final = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
), ),
IndevoltSensorEntityDescription( IndevoltSensorEntityDescription(
key=IndevoltBattery.RATED_CAPACITY_GEN2, key=IndevoltBattery.RATED_CAPACITY,
generation=(2,),
translation_key="rated_capacity", translation_key="rated_capacity",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
), ),
IndevoltSensorEntityDescription( IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_DISCHARGE_LIMIT, key=IndevoltConfig.READ_DISCHARGE_LIMIT,
@@ -132,7 +130,7 @@ SENSORS: Final = (
IndevoltSensorEntityDescription( IndevoltSensorEntityDescription(
key=IndevoltBattery.GEN_2_CYCLE_COUNT, key=IndevoltBattery.GEN_2_CYCLE_COUNT,
generation=(2,), generation=(2,),
translation_key="cycle_count", translation_key="equivalent_full_cycles",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@@ -794,9 +792,58 @@ SENSORS: Final = (
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
# Battery Pack Cycles
IndevoltSensorEntityDescription(
key=IndevoltBattery.MAIN_CYCLES,
generation=(2,),
translation_key="main_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_1_CYCLES,
generation=(2,),
translation_key="battery_pack_1_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_2_CYCLES,
generation=(2,),
translation_key="battery_pack_2_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_3_CYCLES,
generation=(2,),
translation_key="battery_pack_3_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_4_CYCLES,
generation=(2,),
translation_key="battery_pack_4_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
IndevoltSensorEntityDescription(
key=IndevoltBattery.PACK_5_CYCLES,
generation=(2,),
translation_key="battery_pack_5_cycles",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
) )
# Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current) # Sensors per battery pack (SN, SOC, Temperature, MOS Temperature, Voltage, Current, Cycles)
BATTERY_PACK_SENSOR_KEYS = [ BATTERY_PACK_SENSOR_KEYS = [
( (
IndevoltBattery.PACK_1_SERIAL_NUMBER, IndevoltBattery.PACK_1_SERIAL_NUMBER,
@@ -805,6 +852,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_1_MOS_TEMPERATURE, IndevoltBattery.PACK_1_MOS_TEMPERATURE,
IndevoltBattery.PACK_1_VOLTAGE, IndevoltBattery.PACK_1_VOLTAGE,
IndevoltBattery.PACK_1_CURRENT, IndevoltBattery.PACK_1_CURRENT,
IndevoltBattery.PACK_1_CYCLES,
), ),
( (
IndevoltBattery.PACK_2_SERIAL_NUMBER, IndevoltBattery.PACK_2_SERIAL_NUMBER,
@@ -813,6 +861,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_2_MOS_TEMPERATURE, IndevoltBattery.PACK_2_MOS_TEMPERATURE,
IndevoltBattery.PACK_2_VOLTAGE, IndevoltBattery.PACK_2_VOLTAGE,
IndevoltBattery.PACK_2_CURRENT, IndevoltBattery.PACK_2_CURRENT,
IndevoltBattery.PACK_2_CYCLES,
), ),
( (
IndevoltBattery.PACK_3_SERIAL_NUMBER, IndevoltBattery.PACK_3_SERIAL_NUMBER,
@@ -821,6 +870,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_3_MOS_TEMPERATURE, IndevoltBattery.PACK_3_MOS_TEMPERATURE,
IndevoltBattery.PACK_3_VOLTAGE, IndevoltBattery.PACK_3_VOLTAGE,
IndevoltBattery.PACK_3_CURRENT, IndevoltBattery.PACK_3_CURRENT,
IndevoltBattery.PACK_3_CYCLES,
), ),
( (
IndevoltBattery.PACK_4_SERIAL_NUMBER, IndevoltBattery.PACK_4_SERIAL_NUMBER,
@@ -829,6 +879,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_4_MOS_TEMPERATURE, IndevoltBattery.PACK_4_MOS_TEMPERATURE,
IndevoltBattery.PACK_4_VOLTAGE, IndevoltBattery.PACK_4_VOLTAGE,
IndevoltBattery.PACK_4_CURRENT, IndevoltBattery.PACK_4_CURRENT,
IndevoltBattery.PACK_4_CYCLES,
), ),
( (
IndevoltBattery.PACK_5_SERIAL_NUMBER, IndevoltBattery.PACK_5_SERIAL_NUMBER,
@@ -837,6 +888,7 @@ BATTERY_PACK_SENSOR_KEYS = [
IndevoltBattery.PACK_5_MOS_TEMPERATURE, IndevoltBattery.PACK_5_MOS_TEMPERATURE,
IndevoltBattery.PACK_5_VOLTAGE, IndevoltBattery.PACK_5_VOLTAGE,
IndevoltBattery.PACK_5_CURRENT, IndevoltBattery.PACK_5_CURRENT,
IndevoltBattery.PACK_5_CYCLES,
), ),
] ]
+21 -3
View File
@@ -118,6 +118,9 @@
"battery_pack_1_current": { "battery_pack_1_current": {
"name": "Battery pack 1 current" "name": "Battery pack 1 current"
}, },
"battery_pack_1_cycles": {
"name": "Battery pack 1 cycle count"
},
"battery_pack_1_mos_temperature": { "battery_pack_1_mos_temperature": {
"name": "Battery pack 1 MOS temperature" "name": "Battery pack 1 MOS temperature"
}, },
@@ -136,6 +139,9 @@
"battery_pack_2_current": { "battery_pack_2_current": {
"name": "Battery pack 2 current" "name": "Battery pack 2 current"
}, },
"battery_pack_2_cycles": {
"name": "Battery pack 2 cycle count"
},
"battery_pack_2_mos_temperature": { "battery_pack_2_mos_temperature": {
"name": "Battery pack 2 MOS temperature" "name": "Battery pack 2 MOS temperature"
}, },
@@ -154,6 +160,9 @@
"battery_pack_3_current": { "battery_pack_3_current": {
"name": "Battery pack 3 current" "name": "Battery pack 3 current"
}, },
"battery_pack_3_cycles": {
"name": "Battery pack 3 cycle count"
},
"battery_pack_3_mos_temperature": { "battery_pack_3_mos_temperature": {
"name": "Battery pack 3 MOS temperature" "name": "Battery pack 3 MOS temperature"
}, },
@@ -172,6 +181,9 @@
"battery_pack_4_current": { "battery_pack_4_current": {
"name": "Battery pack 4 current" "name": "Battery pack 4 current"
}, },
"battery_pack_4_cycles": {
"name": "Battery pack 4 cycle count"
},
"battery_pack_4_mos_temperature": { "battery_pack_4_mos_temperature": {
"name": "Battery pack 4 MOS temperature" "name": "Battery pack 4 MOS temperature"
}, },
@@ -190,6 +202,9 @@
"battery_pack_5_current": { "battery_pack_5_current": {
"name": "Battery pack 5 current" "name": "Battery pack 5 current"
}, },
"battery_pack_5_cycles": {
"name": "Battery pack 5 cycle count"
},
"battery_pack_5_mos_temperature": { "battery_pack_5_mos_temperature": {
"name": "Battery pack 5 MOS temperature" "name": "Battery pack 5 MOS temperature"
}, },
@@ -226,9 +241,6 @@
"cumulative_production": { "cumulative_production": {
"name": "Cumulative production" "name": "Cumulative production"
}, },
"cycle_count": {
"name": "Cycle count"
},
"daily_production": { "daily_production": {
"name": "Daily production" "name": "Daily production"
}, },
@@ -283,6 +295,9 @@
"self_consumed_prioritized": "Self-consumed prioritized" "self_consumed_prioritized": "Self-consumed prioritized"
} }
}, },
"equivalent_full_cycles": {
"name": "Equivalent full cycles"
},
"grid_frequency": { "grid_frequency": {
"name": "Grid frequency" "name": "Grid frequency"
}, },
@@ -295,6 +310,9 @@
"main_current": { "main_current": {
"name": "Main current" "name": "Main current"
}, },
"main_cycles": {
"name": "Main cycle count"
},
"main_mos_temperature": { "main_mos_temperature": {
"name": "Main MOS temperature" "name": "Main MOS temperature"
}, },
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared", "documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["infrared-protocols==5.6.0"] "requirements": ["infrared-protocols==5.6.1"]
} }
+3 -3
View File
@@ -30,8 +30,8 @@ def log_rate_limits(
) -> None: ) -> None:
"""Output rate limit log line at given level.""" """Output rate limit log line at given level."""
rate_limits = resp["rateLimits"] rate_limits = resp["rateLimits"]
resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) resets_at = dt_util.parse_datetime(rate_limits["resetsAt"])
resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---" resets_at_time = resets_at - dt_util.utcnow() if resets_at is not None else "---"
rate_limit_msg = ( rate_limit_msg = (
"iOS push notification rate limits for %s: " "iOS push notification rate limits for %s: "
"%d sent, %d allowed, %d errors, " "%d sent, %d allowed, %d errors, "
@@ -44,7 +44,7 @@ def log_rate_limits(
rate_limits["successful"], rate_limits["successful"],
rate_limits["maximum"], rate_limits["maximum"],
rate_limits["errors"], rate_limits["errors"],
str(resetsAtTime).split(".", maxsplit=1)[0], str(resets_at_time).split(".", maxsplit=1)[0],
) )
@@ -79,6 +79,17 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
"Unable to connect and retrieve data from israelrail api", "Unable to connect and retrieve data from israelrail api",
) from e ) from e
offset = 0
now = dt_util.now()
while offset < len(train_routes):
route = train_routes[offset]
if route is None:
break
route_departure = departure_time(route)
if route_departure is None or route_departure >= now:
break
offset += 1
return [ return [
DataConnection( DataConnection(
departure=departure_time(train_routes[i]), departure=departure_time(train_routes[i]),
@@ -89,6 +100,6 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
start=station_name_to_id(train_routes[i].trains[0].src), start=station_name_to_id(train_routes[i].trains[0].src),
destination=station_name_to_id(train_routes[i].trains[-1].dst), destination=station_name_to_id(train_routes[i].trains[-1].dst),
) )
for i in range(DEPARTURES_COUNT) for i in range(offset, offset + DEPARTURES_COUNT)
if len(train_routes) > i and train_routes[i] is not None if len(train_routes) > i and train_routes[i] is not None
] ]
+40 -24
View File
@@ -52,30 +52,46 @@ DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
) )
SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
IsraelRailSensorEntityDescription( *[
key="platform", IsraelRailSensorEntityDescription(
translation_key="platform", key=f"platform{i or ''}",
value_fn=lambda data_connection: data_connection.platform, translation_key=f"platform{i or ''}",
), value_fn=lambda data_connection: data_connection.platform,
IsraelRailSensorEntityDescription( index=i,
key="trains", )
translation_key="trains", for i in range(DEPARTURES_COUNT)
value_fn=lambda data_connection: data_connection.trains, ],
), *[
IsraelRailSensorEntityDescription( IsraelRailSensorEntityDescription(
key="train_number", key=f"trains{i or ''}",
translation_key="train_number", translation_key=f"trains{i or ''}",
value_fn=lambda data_connection: data_connection.train_number, value_fn=lambda data_connection: data_connection.trains,
), index=i,
IsraelRailSensorEntityDescription( )
key="departure_delay", for i in range(DEPARTURES_COUNT)
translation_key="departure_delay", ],
device_class=SensorDeviceClass.DURATION, *[
native_unit_of_measurement=UnitOfTime.MINUTES, IsraelRailSensorEntityDescription(
state_class=SensorStateClass.MEASUREMENT, key=f"train_number{i or ''}",
suggested_display_precision=0, translation_key=f"train_number{i or ''}",
value_fn=lambda data_connection: data_connection.departure_delay, value_fn=lambda data_connection: data_connection.train_number,
), index=i,
)
for i in range(DEPARTURES_COUNT)
],
*[
IsraelRailSensorEntityDescription(
key=f"departure_delay{i or ''}",
translation_key=f"departure_delay{i or ''}",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data_connection: data_connection.departure_delay,
index=i,
)
for i in range(DEPARTURES_COUNT)
],
) )

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