This commit is contained in:
Franck Nijhof 2025-05-07 19:13:53 +02:00 committed by GitHub
commit e8bdc7286e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1992 changed files with 112539 additions and 41381 deletions

View File

@ -1,5 +1,6 @@
name: Report an issue with Home Assistant Core name: Report an issue with Home Assistant Core
description: Report an issue with Home Assistant Core. description: Report an issue with Home Assistant Core.
type: Bug
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -32,7 +32,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: translations name: translations
@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1 uses: sigstore/cosign-installer@v3.8.2
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"
@ -457,12 +457,12 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: translations name: translations
@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
context: . # So action will not pull the repository again context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile file: ./script/hassfest/docker/Dockerfile
@ -522,7 +522,7 @@ jobs:
- name: Push Docker image - name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push id: push
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
context: . # So action will not pull the repository again context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile file: ./script/hassfest/docker/Dockerfile
@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
with: with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

View File

@ -40,7 +40,7 @@ env:
CACHE_VERSION: 12 CACHE_VERSION: 12
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9 MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.4" HA_SHORT_VERSION: "2025.5"
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']" ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
@ -249,7 +249,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -294,7 +294,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -334,7 +334,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -374,7 +374,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -484,7 +484,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -587,7 +587,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -620,7 +620,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@v4.5.0 uses: actions/dependency-review-action@v4.6.0
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@ -677,7 +677,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -720,7 +720,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -767,7 +767,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -812,7 +812,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -889,7 +889,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -949,7 +949,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -968,7 +968,7 @@ jobs:
run: | run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: pytest_buckets name: pytest_buckets
- name: Compile English translations - name: Compile English translations
@ -1074,7 +1074,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -1208,7 +1208,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -1312,12 +1312,12 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true' if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v5.4.0 uses: codecov/codecov-action@v5.4.2
with: with:
fail_ci_if_error: true fail_ci_if_error: true
flags: full-suite flags: full-suite
@ -1359,7 +1359,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@ -1454,12 +1454,12 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false' if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v5.4.0 uses: codecov/codecov-action@v5.4.2
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@ -1479,7 +1479,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
pattern: test-results-* pattern: test-results-*
- name: Upload test results to Codecov - name: Upload test results to Codecov

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.13 uses: github/codeql-action/init@v3.28.16
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.13 uses: github/codeql-action/analyze@v3.28.16
with: with:
category: "/language:python" category: "/language:python"

View File

@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v5.5.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@ -138,17 +138,17 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: requirements_diff name: requirements_diff
@ -187,22 +187,22 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@v4.2.1 uses: actions/download-artifact@v4.3.0
with: with:
name: requirements_all_wheels name: requirements_all_wheels

View File

@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.*
homeassistant.components.knocki.* homeassistant.components.knocki.*
homeassistant.components.knx.* homeassistant.components.knx.*
homeassistant.components.kraken.* homeassistant.components.kraken.*
homeassistant.components.kulersky.*
homeassistant.components.lacrosse.* homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.* homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.* homeassistant.components.lamarzocco.*
@ -362,8 +363,10 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.* homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.ntfy.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
homeassistant.components.ohme.*
homeassistant.components.onboarding.* homeassistant.components.onboarding.*
homeassistant.components.oncue.* homeassistant.components.oncue.*
homeassistant.components.onedrive.* homeassistant.components.onedrive.*
@ -383,6 +386,7 @@ homeassistant.components.pandora.*
homeassistant.components.panel_custom.* homeassistant.components.panel_custom.*
homeassistant.components.peblar.* homeassistant.components.peblar.*
homeassistant.components.peco.* homeassistant.components.peco.*
homeassistant.components.pegel_online.*
homeassistant.components.persistent_notification.* homeassistant.components.persistent_notification.*
homeassistant.components.person.* homeassistant.components.person.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
@ -459,6 +463,7 @@ homeassistant.components.slack.*
homeassistant.components.sleepiq.* homeassistant.components.sleepiq.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.smlight.* homeassistant.components.smlight.*
homeassistant.components.smtp.*
homeassistant.components.snooz.* homeassistant.components.snooz.*
homeassistant.components.solarlog.* homeassistant.components.solarlog.*
homeassistant.components.sonarr.* homeassistant.components.sonarr.*

26
CODEOWNERS generated
View File

@ -171,6 +171,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/avea/ @pattyland /homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @danielsjf /homeassistant/components/awair/ @ahayworth @danielsjf
/tests/components/awair/ @ahayworth @danielsjf /tests/components/awair/ @ahayworth @danielsjf
/homeassistant/components/aws_s3/ @tomasbedrich
/tests/components/aws_s3/ @tomasbedrich
/homeassistant/components/axis/ @Kane610 /homeassistant/components/axis/ @Kane610
/tests/components/axis/ @Kane610 /tests/components/axis/ @Kane610
/homeassistant/components/azure_data_explorer/ @kaareseras /homeassistant/components/azure_data_explorer/ @kaareseras
@ -432,7 +434,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 /homeassistant/components/ephember/ @ttroy50 @roberty99
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame /homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame /tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel /homeassistant/components/epion/ @lhgravendeel
@ -704,6 +706,8 @@ build.json @home-assistant/supervisor
/tests/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core
/homeassistant/components/imap/ @jbouwh /homeassistant/components/imap/ @jbouwh
/tests/components/imap/ @jbouwh /tests/components/imap/ @jbouwh
/homeassistant/components/imeon_inverter/ @Imeon-Energy
/tests/components/imeon_inverter/ @Imeon-Energy
/homeassistant/components/imgw_pib/ @bieniu /homeassistant/components/imgw_pib/ @bieniu
/tests/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu
/homeassistant/components/improv_ble/ @emontnemery /homeassistant/components/improv_ble/ @emontnemery
@ -935,6 +939,8 @@ build.json @home-assistant/supervisor
/tests/components/metoffice/ @MrHarcombe @avee87 /tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech /homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87 /homeassistant/components/mikrotik/ @engrbm87
/tests/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87
/homeassistant/components/mill/ @danielhiversen /homeassistant/components/mill/ @danielhiversen
@ -1047,6 +1053,8 @@ build.json @home-assistant/supervisor
/tests/components/nsw_fuel_station/ @nickw444 /tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
/tests/components/nsw_rural_fire_service_feed/ @exxamalte /tests/components/nsw_rural_fire_service_feed/ @exxamalte
/homeassistant/components/ntfy/ @tr4nt0r
/tests/components/ntfy/ @tr4nt0r
/homeassistant/components/nuheat/ @tstabrawa /homeassistant/components/nuheat/ @tstabrawa
/tests/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree /homeassistant/components/nuki/ @pschmitt @pvizeli @pree
@ -1075,8 +1083,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/ombi/ @larssont /homeassistant/components/ombi/ @larssont
/homeassistant/components/onboarding/ @home-assistant/core /homeassistant/components/onboarding/ @home-assistant/core
/tests/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core
/homeassistant/components/oncue/ @bdraco @peterager
/tests/components/oncue/ @bdraco @peterager
/homeassistant/components/ondilo_ico/ @JeromeHXP /homeassistant/components/ondilo_ico/ @JeromeHXP
/tests/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onedrive/ @zweckj /homeassistant/components/onedrive/ @zweckj
@ -1254,6 +1260,8 @@ build.json @home-assistant/supervisor
/tests/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core
/homeassistant/components/refoss/ @ashionky /homeassistant/components/refoss/ @ashionky
/tests/components/refoss/ @ashionky /tests/components/refoss/ @ashionky
/homeassistant/components/rehlko/ @bdraco @peterager
/tests/components/rehlko/ @bdraco @peterager
/homeassistant/components/remote/ @home-assistant/core /homeassistant/components/remote/ @home-assistant/core
/tests/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core
/homeassistant/components/remote_calendar/ @Thomas55555 /homeassistant/components/remote_calendar/ @Thomas55555
@ -1387,7 +1395,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/siren/ @home-assistant/core @raman325 /homeassistant/components/siren/ @home-assistant/core @raman325
/tests/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9 /homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9 /tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob /homeassistant/components/skybell/ @tkdrob
@ -1434,8 +1441,8 @@ build.json @home-assistant/supervisor
/tests/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solax/ @squishykid @Darsstar /homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept @sebfortier2288 /homeassistant/components/soma/ @ratsept
/tests/components/soma/ @ratsept @sebfortier2288 /tests/components/soma/ @ratsept
/homeassistant/components/sonarr/ @ctalkington /homeassistant/components/sonarr/ @ctalkington
/tests/components/sonarr/ @ctalkington /tests/components/sonarr/ @ctalkington
/homeassistant/components/songpal/ @rytilahti @shenxn /homeassistant/components/songpal/ @rytilahti @shenxn
@ -1467,7 +1474,8 @@ build.json @home-assistant/supervisor
/tests/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob
/homeassistant/components/steamist/ @bdraco /homeassistant/components/steamist/ @bdraco
/tests/components/steamist/ @bdraco /tests/components/steamist/ @bdraco
/homeassistant/components/stiebel_eltron/ @fucm /homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS
/tests/components/stiebel_eltron/ @fucm @ThyMYthOS
/homeassistant/components/stookwijzer/ @fwestenberg /homeassistant/components/stookwijzer/ @fwestenberg
/tests/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg
/homeassistant/components/stream/ @hunterjm @uvjustin @allenporter /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter
@ -1670,8 +1678,8 @@ build.json @home-assistant/supervisor
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74 /tests/components/vodafone_station/ @paoloantinori @chemelli74
/homeassistant/components/voip/ @balloob @synesthesiam /homeassistant/components/voip/ @balloob @synesthesiam @jaminh
/tests/components/voip/ @balloob @synesthesiam /tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvooncall/ @molobrakos /homeassistant/components/volvooncall/ @molobrakos

2
Dockerfile generated
View File

@ -31,7 +31,7 @@ RUN \
&& go2rtc --version && go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.6.10 RUN pip3 install uv==0.7.1
WORKDIR /usr/src WORKDIR /usr/src

View File

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View File

@ -53,6 +53,7 @@ from .components import (
logbook as logbook_pre_import, # noqa: F401 logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401
onboarding as onboarding_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401
person as person_pre_import, # noqa: F401
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
repairs as repairs_pre_import, # noqa: F401 repairs as repairs_pre_import, # noqa: F401
search as search_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401
@ -859,8 +860,14 @@ async def _async_set_up_integrations(
integrations, all_integrations = await _async_resolve_domains_and_preload( integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config hass, config
) )
all_domains = set(all_integrations) # Detect all cycles
domains = set(integrations) integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, all_integrations.values(), set(all_integrations)
)
)
all_domains = set(integrations_after_dependencies)
domains = set(integrations) & all_domains
_LOGGER.info( _LOGGER.info(
"Domains to be set up: %s | %s", "Domains to be set up: %s | %s",
@ -868,6 +875,8 @@ async def _async_set_up_integrations(
all_domains - domains, all_domains - domains,
) )
async_set_domains_to_be_loaded(hass, all_domains)
# Initialize recorder # Initialize recorder
if "recorder" in all_domains: if "recorder" in all_domains:
recorder.async_initialize_recorder(hass) recorder.async_initialize_recorder(hass)
@ -900,24 +909,12 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered = { stage_dep_domains_unfiltered = {
dep dep
for domain in stage_domains for domain in stage_domains
for dep in all_integrations[domain].all_dependencies for dep in integrations_after_dependencies[domain]
if dep not in stage_domains if dep not in stage_domains
} }
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains stage_all_domains = stage_domains | stage_dep_domains
stage_all_integrations = {
domain: all_integrations[domain] for domain in stage_all_domains
}
# Detect all cycles
stage_integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, stage_all_integrations.values(), stage_all_domains
)
)
stage_all_domains = set(stage_integrations_after_dependencies)
stage_domains &= stage_all_domains
stage_dep_domains &= stage_all_domains
_LOGGER.info( _LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s", "Setting up stage %s: %s | %s\nDependencies: %s | %s",
@ -928,8 +925,6 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered - stage_dep_domains, stage_dep_domains_unfiltered - stage_dep_domains,
) )
async_set_domains_to_be_loaded(hass, stage_all_domains)
if timeout is None: if timeout is None:
await _async_setup_multi_components(hass, stage_all_domains, config) await _async_setup_multi_components(hass, stage_all_domains, config)
continue continue

View File

@ -1,5 +1,12 @@
{ {
"domain": "amazon", "domain": "amazon",
"name": "Amazon", "name": "Amazon",
"integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"] "integrations": [
"alexa",
"amazon_polly",
"aws",
"aws_s3",
"fire_tv",
"route53"
]
} }

View File

@ -6,6 +6,7 @@
"google_assistant_sdk", "google_assistant_sdk",
"google_cloud", "google_cloud",
"google_drive", "google_drive",
"google_gemini",
"google_generative_ai_conversation", "google_generative_ai_conversation",
"google_mail", "google_mail",
"google_maps", "google_maps",

View File

@ -0,0 +1,6 @@
{
"domain": "nuki",
"name": "Nuki",
"integrations": ["nuki"],
"iot_standards": ["matter"]
}

View File

@ -67,6 +67,7 @@ POLLEN_CATEGORY_MAP = {
2: "moderate", 2: "moderate",
3: "high", 3: "high",
4: "very_high", 4: "very_high",
5: "extreme",
} }
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)

View File

@ -72,10 +72,11 @@
"level": { "level": {
"name": "Level", "name": "Level",
"state": { "state": {
"high": "High", "extreme": "Extreme",
"low": "Low", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "Moderate", "moderate": "Moderate",
"very_high": "Very high" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -89,10 +90,11 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -123,10 +125,11 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -167,10 +170,11 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -181,10 +185,11 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }
@ -195,10 +200,11 @@
"level": { "level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": { "state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" "very_high": "[%key:common::state::very_high%]"
} }
} }
} }

View File

@ -2,25 +2,38 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import CONNECTION_TYPE, LOCAL
from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
"""Set up Adax from a config entry.""" """Set up Adax from a config entry."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
local_coordinator = AdaxLocalCoordinator(hass, entry)
entry.runtime_data = local_coordinator
else:
cloud_coordinator = AdaxCloudCoordinator(hass, entry)
entry.runtime_data = cloud_coordinator
await entry.runtime_data.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_migrate_entry(
hass: HomeAssistant, config_entry: AdaxConfigEntry
) -> bool:
"""Migrate old entry.""" """Migrate old entry."""
# convert title and unique_id to string # convert title and unique_id to string
if config_entry.version == 1: if config_entry.version == 1:

View File

@ -12,57 +12,42 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
CONF_IP_ADDRESS,
CONF_PASSWORD,
CONF_TOKEN,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
PRECISION_WHOLE, PRECISION_WHOLE,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL from . import AdaxConfigEntry
from .const import CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import AdaxCloudCoordinator, AdaxLocalCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: AdaxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Adax thermostat with config flow.""" """Set up the Adax thermostat with config flow."""
if entry.data.get(CONNECTION_TYPE) == LOCAL: if entry.data.get(CONNECTION_TYPE) == LOCAL:
adax_data_handler = AdaxLocal( local_coordinator = cast(AdaxLocalCoordinator, entry.runtime_data)
entry.data[CONF_IP_ADDRESS],
entry.data[CONF_TOKEN],
websession=async_get_clientsession(hass, verify_ssl=False),
)
async_add_entities( async_add_entities(
[LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True [LocalAdaxDevice(local_coordinator, entry.data[CONF_UNIQUE_ID])],
)
else:
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
async_add_entities(
AdaxDevice(cloud_coordinator, device_id)
for device_id in cloud_coordinator.data
) )
return
adax_data_handler = Adax(
entry.data[ACCOUNT_ID],
entry.data[CONF_PASSWORD],
websession=async_get_clientsession(hass),
)
async_add_entities(
(
AdaxDevice(room, adax_data_handler)
for room in await adax_data_handler.get_rooms()
),
True,
)
class AdaxDevice(ClimateEntity): class AdaxDevice(CoordinatorEntity[AdaxCloudCoordinator], ClimateEntity):
"""Representation of a heater.""" """Representation of a heater."""
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
@ -76,20 +61,37 @@ class AdaxDevice(ClimateEntity):
_attr_target_temperature_step = PRECISION_WHOLE _attr_target_temperature_step = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: def __init__(
self,
coordinator: AdaxCloudCoordinator,
device_id: str,
) -> None:
"""Initialize the heater.""" """Initialize the heater."""
self._device_id = heater_data["id"] super().__init__(coordinator)
self._adax_data_handler = adax_data_handler self._adax_data_handler: Adax = coordinator.adax_data_handler
self._device_id = device_id
self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" self._attr_name = self.room["name"]
self._attr_unique_id = f"{self.room['homeId']}_{self._device_id}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, heater_data["id"])}, identifiers={(DOMAIN, device_id)},
# Instead of setting the device name to the entity name, adax # Instead of setting the device name to the entity name, adax
# should be updated to set has_entity_name = True, and set the entity # should be updated to set has_entity_name = True, and set the entity
# name to None # name to None
name=cast(str | None, self.name), name=cast(str | None, self.name),
manufacturer="Adax", manufacturer="Adax",
) )
self._apply_data(self.room)
@property
def available(self) -> bool:
"""Whether the entity is available or not."""
return super().available and self._device_id in self.coordinator.data
@property
def room(self) -> dict[str, Any]:
"""Gets the data for this particular device."""
return self.coordinator.data[self._device_id]
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode.""" """Set hvac mode."""
@ -104,7 +106,9 @@ class AdaxDevice(ClimateEntity):
) )
else: else:
return return
await self._adax_data_handler.update()
# Request data refresh from source to verify that update was successful
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""
@ -114,28 +118,31 @@ class AdaxDevice(ClimateEntity):
self._device_id, temperature, True self._device_id, temperature, True
) )
async def async_update(self) -> None: @callback
"""Get the latest data.""" def _handle_coordinator_update(self) -> None:
for room in await self._adax_data_handler.get_rooms(): """Handle updated data from the coordinator."""
if room["id"] != self._device_id: if room := self.room:
continue self._apply_data(room)
self._attr_name = room["name"] super()._handle_coordinator_update()
self._attr_current_temperature = room.get("temperature")
self._attr_target_temperature = room.get("targetTemperature") def _apply_data(self, room: dict[str, Any]) -> None:
if room["heatingEnabled"]: """Update the appropriate attributues based on received data."""
self._attr_hvac_mode = HVACMode.HEAT self._attr_current_temperature = room.get("temperature")
self._attr_icon = "mdi:radiator" self._attr_target_temperature = room.get("targetTemperature")
else: if room["heatingEnabled"]:
self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_mode = HVACMode.HEAT
self._attr_icon = "mdi:radiator-off" self._attr_icon = "mdi:radiator"
return else:
self._attr_hvac_mode = HVACMode.OFF
self._attr_icon = "mdi:radiator-off"
class LocalAdaxDevice(ClimateEntity): class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
"""Representation of a heater.""" """Representation of a heater."""
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_hvac_mode = HVACMode.HEAT _attr_hvac_mode = HVACMode.OFF
_attr_icon = "mdi:radiator-off"
_attr_max_temp = 35 _attr_max_temp = 35
_attr_min_temp = 5 _attr_min_temp = 5
_attr_supported_features = ( _attr_supported_features = (
@ -146,9 +153,10 @@ class LocalAdaxDevice(ClimateEntity):
_attr_target_temperature_step = PRECISION_WHOLE _attr_target_temperature_step = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: def __init__(self, coordinator: AdaxLocalCoordinator, unique_id: str) -> None:
"""Initialize the heater.""" """Initialize the heater."""
self._adax_data_handler = adax_data_handler super().__init__(coordinator)
self._adax_data_handler: AdaxLocal = coordinator.adax_data_handler
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
@ -169,17 +177,20 @@ class LocalAdaxDevice(ClimateEntity):
return return
await self._adax_data_handler.set_target_temperature(temperature) await self._adax_data_handler.set_target_temperature(temperature)
async def async_update(self) -> None: @callback
"""Get the latest data.""" def _handle_coordinator_update(self) -> None:
data = await self._adax_data_handler.get_status() """Handle updated data from the coordinator."""
self._attr_current_temperature = data["current_temperature"] if data := self.coordinator.data:
self._attr_available = self._attr_current_temperature is not None self._attr_current_temperature = data["current_temperature"]
if (target_temp := data["target_temperature"]) == 0: self._attr_available = self._attr_current_temperature is not None
self._attr_hvac_mode = HVACMode.OFF if (target_temp := data["target_temperature"]) == 0:
self._attr_icon = "mdi:radiator-off" self._attr_hvac_mode = HVACMode.OFF
if target_temp == 0: self._attr_icon = "mdi:radiator-off"
self._attr_target_temperature = self._attr_min_temp if target_temp == 0:
else: self._attr_target_temperature = self._attr_min_temp
self._attr_hvac_mode = HVACMode.HEAT else:
self._attr_icon = "mdi:radiator" self._attr_hvac_mode = HVACMode.HEAT
self._attr_target_temperature = target_temp self._attr_icon = "mdi:radiator"
self._attr_target_temperature = target_temp
super()._handle_coordinator_update()

View File

@ -1,5 +1,6 @@
"""Constants for the Adax integration.""" """Constants for the Adax integration."""
import datetime
from typing import Final from typing import Final
ACCOUNT_ID: Final = "account_id" ACCOUNT_ID: Final = "account_id"
@ -9,3 +10,5 @@ DOMAIN: Final = "adax"
LOCAL = "Local" LOCAL = "Local"
WIFI_SSID = "wifi_ssid" WIFI_SSID = "wifi_ssid"
WIFI_PSWD = "wifi_pswd" WIFI_PSWD = "wifi_pswd"
SCAN_INTERVAL = datetime.timedelta(seconds=60)

View File

@ -0,0 +1,71 @@
"""DataUpdateCoordinator for the Adax component."""
import logging
from typing import Any, cast
from adax import Adax
from adax_local import Adax as AdaxLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ACCOUNT_ID, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AdaxConfigEntry = ConfigEntry[AdaxCloudCoordinator | AdaxLocalCoordinator]
class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Coordinator for updating data to and from Adax (cloud)."""
def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None:
"""Initialize the Adax coordinator used for Cloud mode."""
super().__init__(
hass,
config_entry=entry,
logger=_LOGGER,
name="AdaxCloud",
update_interval=SCAN_INTERVAL,
)
self.adax_data_handler = Adax(
entry.data[ACCOUNT_ID],
entry.data[CONF_PASSWORD],
websession=async_get_clientsession(hass),
)
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch data from the Adax."""
rooms = await self.adax_data_handler.get_rooms() or []
return {r["id"]: r for r in rooms}
class AdaxLocalCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
"""Coordinator for updating data to and from Adax (local)."""
def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None:
"""Initialize the Adax coordinator used for Local mode."""
super().__init__(
hass,
config_entry=entry,
logger=_LOGGER,
name="AdaxLocal",
update_interval=SCAN_INTERVAL,
)
self.adax_data_handler = AdaxLocal(
entry.data[CONF_IP_ADDRESS],
entry.data[CONF_TOKEN],
websession=async_get_clientsession(hass, verify_ssl=False),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the Adax."""
if result := await self.adax_data_handler.get_status():
return cast(dict[str, Any], result)
raise UpdateFailed("Got invalid status from device")

View File

@ -68,8 +68,8 @@
"led_bar_mode": { "led_bar_mode": {
"name": "LED bar mode", "name": "LED bar mode",
"state": { "state": {
"off": "Off", "off": "[%key:common::state::off%]",
"co2": "Carbon dioxide", "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "Particulate matter" "pm": "Particulate matter"
} }
}, },
@ -143,8 +143,8 @@
"led_bar_mode": { "led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": { "state": {
"off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", "off": "[%key:common::state::off%]",
"co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
} }
}, },

View File

@ -16,8 +16,8 @@
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City", "city": "City",
"country": "Country", "state": "State",
"state": "State" "country": "[%key:common::config_flow::data::country%]"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@ -56,12 +56,12 @@
"sensor": { "sensor": {
"pollutant_label": { "pollutant_label": {
"state": { "state": {
"co": "Carbon monoxide", "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"n2": "Nitrogen dioxide", "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "Ozone", "o3": "[%key:component::sensor::entity_component::ozone::name%]",
"p1": "PM10", "p1": "[%key:component::sensor::entity_component::pm10::name%]",
"p2": "PM2.5", "p2": "[%key:component::sensor::entity_component::pm25::name%]",
"s2": "Sulfur dioxide" "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
} }
}, },
"pollutant_level": { "pollutant_level": {

View File

@ -9,6 +9,8 @@ from aioairzone.const import (
AZD_HUMIDITY, AZD_HUMIDITY,
AZD_TEMP, AZD_TEMP,
AZD_TEMP_UNIT, AZD_TEMP_UNIT,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_SIGNAL,
AZD_WEBSERVER, AZD_WEBSERVER,
AZD_WIFI_RSSI, AZD_WIFI_RSSI,
AZD_ZONES, AZD_ZONES,
@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_SIGNAL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_signal",
),
) )

View File

@ -76,6 +76,9 @@
"sensor": { "sensor": {
"rssi": { "rssi": {
"name": "RSSI" "name": "RSSI"
},
"thermostat_signal": {
"name": "Signal strength"
} }
} }
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"], "loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.11"] "requirements": ["aioairzone-cloud==0.6.12"]
} }

View File

@ -32,9 +32,9 @@
"air_quality": { "air_quality": {
"name": "Air Quality mode", "name": "Air Quality mode",
"state": { "state": {
"off": "Off", "off": "[%key:common::state::off%]",
"on": "On", "on": "[%key:common::state::on%]",
"auto": "Auto" "auto": "[%key:common::state::auto%]"
} }
}, },
"modes": { "modes": {

View File

@ -719,7 +719,7 @@ class LockCapabilities(AlexaEntity):
yield Alexa(self.entity) yield Alexa(self.entity)
@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) @ENTITY_ADAPTERS.register(media_player.DOMAIN)
class MediaPlayerCapabilities(AlexaEntity): class MediaPlayerCapabilities(AlexaEntity):
"""Class to represent MediaPlayer capabilities.""" """Class to represent MediaPlayer capabilities."""
@ -757,9 +757,7 @@ class MediaPlayerCapabilities(AlexaEntity):
if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE: if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE:
inputs = AlexaInputController.get_valid_inputs( inputs = AlexaInputController.get_valid_inputs(
self.entity.attributes.get( self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, [])
media_player.const.ATTR_INPUT_SOURCE_LIST, []
)
) )
if len(inputs) > 0: if len(inputs) > 0:
yield AlexaInputController(self.entity) yield AlexaInputController(self.entity)
@ -776,8 +774,7 @@ class MediaPlayerCapabilities(AlexaEntity):
and domain != "denonavr" and domain != "denonavr"
): ):
inputs = AlexaEqualizerController.get_valid_inputs( inputs = AlexaEqualizerController.get_valid_inputs(
self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or []
or []
) )
if len(inputs) > 0: if len(inputs) > 0:
yield AlexaEqualizerController(self.entity) yield AlexaEqualizerController(self.entity)

View File

@ -566,7 +566,7 @@ async def async_api_set_volume(
data: dict[str, Any] = { data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id, ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
} }
await hass.services.async_call( await hass.services.async_call(
@ -589,7 +589,7 @@ async def async_api_select_input(
# Attempt to map the ALL UPPERCASE payload name to a source. # Attempt to map the ALL UPPERCASE payload name to a source.
# Strips trailing 1 to match single input devices. # Strips trailing 1 to match single input devices.
source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST) or [] source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
for source in source_list: for source in source_list:
formatted_source = ( formatted_source = (
source.lower().replace("-", "").replace("_", "").replace(" ", "") source.lower().replace("-", "").replace("_", "").replace(" ", "")
@ -611,7 +611,7 @@ async def async_api_select_input(
data: dict[str, Any] = { data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id, ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_INPUT_SOURCE: media_input, media_player.ATTR_INPUT_SOURCE: media_input,
} }
await hass.services.async_call( await hass.services.async_call(
@ -636,7 +636,7 @@ async def async_api_adjust_volume(
volume_delta = int(directive.payload["volume"]) volume_delta = int(directive.payload["volume"])
entity = directive.entity entity = directive.entity
current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL] current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL]
# read current state # read current state
try: try:
@ -648,7 +648,7 @@ async def async_api_adjust_volume(
data: dict[str, Any] = { data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id, ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, media_player.ATTR_MEDIA_VOLUME_LEVEL: volume,
} }
await hass.services.async_call( await hass.services.async_call(
@ -709,7 +709,7 @@ async def async_api_set_mute(
entity = directive.entity entity = directive.entity
data: dict[str, Any] = { data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id, ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
} }
await hass.services.async_call( await hass.services.async_call(
@ -1708,15 +1708,13 @@ async def async_api_changechannel(
data: dict[str, Any] = { data: dict[str, Any] = {
ATTR_ENTITY_ID: entity.entity_id, ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_CONTENT_ID: channel, media_player.ATTR_MEDIA_CONTENT_ID: channel,
media_player.const.ATTR_MEDIA_CONTENT_TYPE: ( media_player.ATTR_MEDIA_CONTENT_TYPE: (media_player.MediaType.CHANNEL),
media_player.const.MEDIA_TYPE_CHANNEL
),
} }
await hass.services.async_call( await hass.services.async_call(
entity.domain, entity.domain,
media_player.const.SERVICE_PLAY_MEDIA, media_player.SERVICE_PLAY_MEDIA,
data, data,
blocking=False, blocking=False,
context=context, context=context,
@ -1825,13 +1823,13 @@ async def async_api_set_eq_mode(
context: ha.Context, context: ha.Context,
) -> AlexaResponse: ) -> AlexaResponse:
"""Process a SetMode request for EqualizerController.""" """Process a SetMode request for EqualizerController."""
mode = directive.payload["mode"] mode: str = directive.payload["mode"]
entity = directive.entity entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST)
if sound_mode_list and mode.lower() in sound_mode_list: if sound_mode_list and mode.lower() in sound_mode_list:
data[media_player.const.ATTR_SOUND_MODE] = mode.lower() data[media_player.ATTR_SOUND_MODE] = mode.lower()
else: else:
msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}"
raise AlexaInvalidValueError(msg) raise AlexaInvalidValueError(msg)

View File

@ -3,10 +3,10 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import json import json
import logging import logging
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4 from uuid import uuid4
@ -260,10 +260,10 @@ async def async_enable_proactive_mode(
def extra_significant_check( def extra_significant_check(
hass: HomeAssistant, hass: HomeAssistant,
old_state: str, old_state: str,
old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], old_attrs: Mapping[Any, Any],
old_extra_arg: Any, old_extra_arg: Any,
new_state: str, new_state: str,
new_attrs: dict[str, Any] | MappingProxyType[Any, Any], new_attrs: Mapping[Any, Any],
new_extra_arg: Any, new_extra_arg: Any,
) -> bool: ) -> bool:
"""Check if the serialized data has changed.""" """Check if the serialized data has changed."""

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"], "loggers": ["boto3", "botocore", "s3transfer"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["boto3==1.34.131"] "requirements": ["boto3==1.37.1"]
} }

View File

@ -3,12 +3,12 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"tracked_addons": "Addons", "tracked_addons": "Add-ons",
"tracked_integrations": "Integrations", "tracked_integrations": "Integrations",
"tracked_custom_integrations": "Custom integrations" "tracked_custom_integrations": "Custom integrations"
}, },
"data_description": { "data_description": {
"tracked_addons": "Select the addons you want to track", "tracked_addons": "Select the add-ons you want to track",
"tracked_integrations": "Select the integrations you want to track", "tracked_integrations": "Select the integrations you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track" "tracked_custom_integrations": "Select the custom integrations you want to track"
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pydroid-ipcam==2.0.0"] "requirements": ["pydroid-ipcam==3.0.0"]
} }

View File

@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_key_command(key_code, direction) self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc
def _send_launch_app_command(self, app_link: str) -> None: def _send_launch_app_command(self, app_link: str) -> None:
@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_launch_app_command(app_link) self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc

View File

@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
await asyncio.sleep(delay_secs) await asyncio.sleep(delay_secs)
except ConnectionClosed as exc: except ConnectionClosed as exc:
raise HomeAssistantError( raise HomeAssistantError(
"Connection to Android TV device is closed" translation_domain=DOMAIN, translation_key="connection_closed"
) from exc ) from exc

View File

@ -54,5 +54,10 @@
} }
} }
} }
},
"exceptions": {
"connection_closed": {
"message": "Connection to the Android TV device is closed"
}
} }
} }

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from functools import partial from functools import partial
import logging import logging
from types import MappingProxyType from types import MappingProxyType
@ -52,7 +53,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
RECOMMENDED_OPTIONS = { RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
} }
@ -134,9 +135,8 @@ class AnthropicOptionsFlow(OptionsFlow):
if user_input is not None: if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none": if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API) user_input.pop(CONF_LLM_HASS_API, None)
if user_input.get( if user_input.get(
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
@ -151,12 +151,16 @@ class AnthropicOptionsFlow(OptionsFlow):
options = { options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT], CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
} }
suggested_values = options.copy() suggested_values = options.copy()
if not suggested_values.get(CONF_PROMPT): if not suggested_values.get(CONF_PROMPT):
suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT
if (
suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API)
) and isinstance(suggested_llm_apis, str):
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
schema = self.add_suggested_values_to_schema( schema = self.add_suggested_values_to_schema(
vol.Schema(anthropic_config_option_schema(self.hass, options)), vol.Schema(anthropic_config_option_schema(self.hass, options)),
@ -172,28 +176,22 @@ class AnthropicOptionsFlow(OptionsFlow):
def anthropic_config_option_schema( def anthropic_config_option_schema(
hass: HomeAssistant, hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any], options: Mapping[str, Any],
) -> dict: ) -> dict:
"""Return a schema for Anthropic completion options.""" """Return a schema for Anthropic completion options."""
hass_apis: list[SelectOptionDict] = [ hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict( SelectOptionDict(
label=api.name, label=api.name,
value=api.id, value=api.id,
) )
for api in llm.async_get_apis(hass) for api in llm.async_get_apis(hass)
) ]
schema = { schema = {
vol.Optional(CONF_PROMPT): TemplateSelector(), vol.Optional(CONF_PROMPT): TemplateSelector(),
vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector( vol.Optional(
SelectSelectorConfig(options=hass_apis) CONF_LLM_HASS_API,
), ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required( vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool, ): bool,

View File

@ -9,11 +9,13 @@ from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN from anthropic._types import NOT_GIVEN
from anthropic.types import ( from anthropic.types import (
InputJSONDelta, InputJSONDelta,
MessageDeltaUsage,
MessageParam, MessageParam,
MessageStreamEvent, MessageStreamEvent,
RawContentBlockDeltaEvent, RawContentBlockDeltaEvent,
RawContentBlockStartEvent, RawContentBlockStartEvent,
RawContentBlockStopEvent, RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent, RawMessageStartEvent,
RawMessageStopEvent, RawMessageStopEvent,
RedactedThinkingBlock, RedactedThinkingBlock,
@ -31,6 +33,7 @@ from anthropic.types import (
ToolResultBlockParam, ToolResultBlockParam,
ToolUseBlock, ToolUseBlock,
ToolUseBlockParam, ToolUseBlockParam,
Usage,
) )
from voluptuous_openapi import convert from voluptuous_openapi import convert
@ -162,7 +165,8 @@ def _convert_content(
return messages return messages
async def _transform_stream( async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent], result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam], messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
@ -207,6 +211,7 @@ async def _transform_stream(
| None | None
) = None ) = None
current_tool_args: str current_tool_args: str
input_usage: Usage | None = None
async for response in result: async for response in result:
LOGGER.debug("Received response: %s", response) LOGGER.debug("Received response: %s", response)
@ -215,6 +220,7 @@ async def _transform_stream(
if response.message.role != "assistant": if response.message.role != "assistant":
raise ValueError("Unexpected message role") raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[]) current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent): elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock): if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam( current_block = ToolUseBlockParam(
@ -265,32 +271,54 @@ async def _transform_stream(
if current_block is None: if current_block is None:
raise ValueError("Unexpected stop event without a current block") raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use": if current_block["type"] == "tool_use":
tool_block = cast(ToolUseBlockParam, current_block) # tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_args = json.loads(current_tool_args) if current_tool_args else {}
tool_block["input"] = tool_args current_block["input"] = tool_args
yield { yield {
"tool_calls": [ "tool_calls": [
llm.ToolInput( llm.ToolInput(
id=tool_block["id"], id=current_block["id"],
tool_name=tool_block["name"], tool_name=current_block["name"],
tool_args=tool_args, tool_args=tool_args,
) )
] ]
} }
elif current_block["type"] == "thinking": elif current_block["type"] == "thinking":
thinking_block = cast(ThinkingBlockParam, current_block) # thinking block
LOGGER.debug("Thinking: %s", thinking_block["thinking"]) LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None: if current_message is None:
raise ValueError("Unexpected stop event without a current message") raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr] current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
elif isinstance(response, RawMessageStopEvent): elif isinstance(response, RawMessageStopEvent):
if current_message is not None: if current_message is not None:
messages.append(current_message) messages.append(current_message)
current_message = None current_message = None
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
class AnthropicConversationEntity( class AnthropicConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent conversation.ConversationEntity, conversation.AbstractConversationAgent
): ):
@ -393,7 +421,8 @@ class AnthropicConversationEntity(
[ [
content content
async for content in chat_log.async_add_delta_content_stream( async for content in chat_log.async_add_delta_content_stream(
user_input.agent_id, _transform_stream(stream, messages) user_input.agent_id,
_transform_stream(chat_log, stream, messages),
) )
if not isinstance(content, conversation.AssistantContent) if not isinstance(content, conversation.AssistantContent)
] ]

View File

@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
"""Initialize the APCUPSd binary device.""" """Initialize the APCUPSd binary device."""
super().__init__(coordinator, context=description.key.upper()) super().__init__(coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := coordinator.data.serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info self._attr_device_info = coordinator.device_info
@property @property

View File

@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
self._host = host self._host = host
self._port = port self._port = port
@property
def unique_device_id(self) -> str:
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
return self.data.serial_no or self.config_entry.entry_id
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the DeviceInfo of this APC UPS, if serial number is available.""" """Return the DeviceInfo of this APC UPS, if serial number is available."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)}, identifiers={(DOMAIN, self.unique_device_id)},
model=self.data.model, model=self.data.model,
manufacturer="APC", manufacturer="APC",
name=self.data.name or "APC UPS", name=self.data.name or "APC UPS",
@ -108,4 +113,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
data = await aioapcaccess.request_status(self._host, self._port) data = await aioapcaccess.request_status(self._host, self._port)
return APCUPSdData(data) return APCUPSdData(data)
except (OSError, asyncio.IncompleteReadError) as error: except (OSError, asyncio.IncompleteReadError) as error:
raise UpdateFailed(error) from error raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from error

View File

@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator=coordinator, context=description.key.upper()) super().__init__(coordinator=coordinator, context=description.key.upper())
# Set up unique id and device info if serial number is available.
if (serial_no := coordinator.data.serial_no) is not None:
self._attr_unique_id = f"{serial_no}_{description.key}"
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info self._attr_device_info = coordinator.device_info
# Initial update of attributes. # Initial update of attributes.

View File

@ -93,7 +93,7 @@
"name": "Internal temperature" "name": "Internal temperature"
}, },
"last_self_test": { "last_self_test": {
"name": "Last self test" "name": "Last self-test"
}, },
"last_transfer": { "last_transfer": {
"name": "Last transfer" "name": "Last transfer"
@ -177,7 +177,7 @@
"name": "Restore requirement" "name": "Restore requirement"
}, },
"self_test_result": { "self_test_result": {
"name": "Self test result" "name": "Self-test result"
}, },
"sensitivity": { "sensitivity": {
"name": "Sensitivity" "name": "Sensitivity"
@ -195,7 +195,7 @@
"name": "Status" "name": "Status"
}, },
"self_test_interval": { "self_test_interval": {
"name": "Self test interval" "name": "Self-test interval"
}, },
"time_left": { "time_left": {
"name": "Time left" "name": "Time left"
@ -219,5 +219,10 @@
"name": "Transfer to battery" "name": "Transfer to battery"
} }
} }
},
"exceptions": {
"cannot_connect": {
"message": "Cannot connect to APC UPS Daemon."
}
} }
} }

View File

@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
config_entry: ApSystemsConfigEntry config_entry: ApSystemsConfigEntry
device_version: str device_version: str
battery_system: bool
def __init__( def __init__(
self, self,
@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
self.api.max_power = device_info.maxPower self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower self.api.min_power = device_info.minPower
self.device_version = device_info.devVer self.device_version = device_info.devVer
self.battery_system = device_info.isBatterySystem
async def _async_update_data(self) -> ApSystemsSensorData: async def _async_update_data(self) -> ApSystemsSensorData:
try: try:

View File

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems", "documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["apsystems-ez1==2.4.0"] "loggers": ["APsystemsEZ1"],
"requirements": ["apsystems-ez1==2.6.0"]
} }

View File

@ -21,7 +21,7 @@
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"off_grid_status": { "off_grid_status": {
"name": "Off grid status" "name": "Off-grid status"
}, },
"dc_1_short_circuit_error_status": { "dc_1_short_circuit_error_status": {
"name": "DC 1 short circuit error status" "name": "DC 1 short circuit error status"

View File

@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
super().__init__(data) super().__init__(data)
self._api = data.coordinator.api self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_inverter_status" self._attr_unique_id = f"{data.device_id}_inverter_status"
if data.coordinator.battery_system:
self._attr_available = False
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update switch status and availability.""" """Update switch status and availability."""

View File

@ -36,9 +36,9 @@
"wi_fi_strength": { "wi_fi_strength": {
"name": "Wi-Fi strength", "name": "Wi-Fi strength",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"medium": "Medium", "medium": "[%key:common::state::medium%]",
"high": "High" "high": "[%key:common::state::high%]"
} }
} }
} }

View File

@ -26,7 +26,7 @@
"sensor": { "sensor": {
"threshold": { "threshold": {
"state": { "state": {
"error": "Error", "error": "[%key:common::state::error%]",
"green": "Green", "green": "Green",
"yellow": "Yellow", "yellow": "Yellow",
"red": "Red" "red": "Red"

View File

@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from pyasuswrt import AsusWrtError from pyasuswrt import AsusWrtError
@ -363,7 +362,7 @@ class AsusWrtRouter:
"""Add a function to call when router is closed.""" """Add a function to call when router is closed."""
self._on_close.append(func) self._on_close.append(func)
def update_options(self, new_options: MappingProxyType[str, Any]) -> bool: def update_options(self, new_options: Mapping[str, Any]) -> bool:
"""Update router options.""" """Update router options."""
req_reload = False req_reload = False
for name, new_opt in new_options.items(): for name, new_opt in new_options.items():

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"]
} }

View File

@ -18,6 +18,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_MODE, ATTR_MODE,
ATTR_NAME, ATTR_NAME,
CONF_ACTIONS,
CONF_ALIAS, CONF_ALIAS,
CONF_CONDITIONS, CONF_CONDITIONS,
CONF_DEVICE_ID, CONF_DEVICE_ID,
@ -27,6 +28,7 @@ from homeassistant.const import (
CONF_MODE, CONF_MODE,
CONF_PATH, CONF_PATH,
CONF_PLATFORM, CONF_PLATFORM,
CONF_TRIGGERS,
CONF_VARIABLES, CONF_VARIABLES,
CONF_ZONE, CONF_ZONE,
EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STARTED,
@ -86,11 +88,9 @@ from homeassistant.util.hass_dict import HassKey
from .config import AutomationConfig, ValidationStatus from .config import AutomationConfig, ValidationStatus
from .const import ( from .const import (
CONF_ACTIONS,
CONF_INITIAL_STATE, CONF_INITIAL_STATE,
CONF_TRACE, CONF_TRACE,
CONF_TRIGGER_VARIABLES, CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DEFAULT_INITIAL_STATE, DEFAULT_INITIAL_STATE,
DOMAIN, DOMAIN,
LOGGER, LOGGER,

View File

@ -14,11 +14,15 @@ from homeassistant.components import blueprint
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
from homeassistant.config import config_per_platform, config_without_domain from homeassistant.config import config_per_platform, config_without_domain
from homeassistant.const import ( from homeassistant.const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_ALIAS, CONF_ALIAS,
CONF_CONDITION, CONF_CONDITION,
CONF_CONDITIONS, CONF_CONDITIONS,
CONF_DESCRIPTION, CONF_DESCRIPTION,
CONF_ID, CONF_ID,
CONF_TRIGGER,
CONF_TRIGGERS,
CONF_VARIABLES, CONF_VARIABLES,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -30,14 +34,10 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.yaml.input import UndefinedSubstitution from homeassistant.util.yaml.input import UndefinedSubstitution
from .const import ( from .const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_HIDE_ENTITY, CONF_HIDE_ENTITY,
CONF_INITIAL_STATE, CONF_INITIAL_STATE,
CONF_TRACE, CONF_TRACE,
CONF_TRIGGER,
CONF_TRIGGER_VARIABLES, CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
) )
@ -58,34 +58,9 @@ _MINIMAL_PLATFORM_SCHEMA = vol.Schema(
def _backward_compat_schema(value: Any | None) -> Any: def _backward_compat_schema(value: Any | None) -> Any:
"""Backward compatibility for automations.""" """Backward compatibility for automations."""
if not isinstance(value, dict): value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value)
return value value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value)
return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value)
# `trigger` has been renamed to `triggers`
if CONF_TRIGGER in value:
if CONF_TRIGGERS in value:
raise vol.Invalid(
"Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only."
)
value[CONF_TRIGGERS] = value.pop(CONF_TRIGGER)
# `condition` has been renamed to `conditions`
if CONF_CONDITION in value:
if CONF_CONDITIONS in value:
raise vol.Invalid(
"Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only."
)
value[CONF_CONDITIONS] = value.pop(CONF_CONDITION)
# `action` has been renamed to `actions`
if CONF_ACTION in value:
if CONF_ACTIONS in value:
raise vol.Invalid(
"Cannot specify both 'action' and 'actions'. Please use 'actions' only."
)
value[CONF_ACTIONS] = value.pop(CONF_ACTION)
return value
PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA = vol.All(

View File

@ -2,10 +2,6 @@
import logging import logging
CONF_ACTION = "action"
CONF_ACTIONS = "actions"
CONF_TRIGGER = "trigger"
CONF_TRIGGERS = "triggers"
CONF_TRIGGER_VARIABLES = "trigger_variables" CONF_TRIGGER_VARIABLES = "trigger_variables"
DOMAIN = "automation" DOMAIN = "automation"

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"], "loggers": ["aiobotocore", "botocore"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"]
} }

View File

@ -0,0 +1,82 @@
"""The AWS S3 integration."""
from __future__ import annotations
import logging
from typing import cast
from aiobotocore.client import AioBaseClient as S3Client
from aiobotocore.session import AioSession
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_SECRET_ACCESS_KEY,
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
)
type S3ConfigEntry = ConfigEntry[S3Client]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
"""Set up S3 from a config entry."""
data = cast(dict, entry.data)
try:
session = AioSession()
# pylint: disable-next=unnecessary-dunder-call
client = await session.create_client(
"s3",
endpoint_url=data.get(CONF_ENDPOINT_URL),
aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY],
aws_access_key_id=data[CONF_ACCESS_KEY_ID],
).__aenter__()
await client.head_bucket(Bucket=data[CONF_BUCKET])
except ClientError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
) from err
except ParamValidationError as err:
if "Invalid bucket name" in str(err):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_bucket_name",
) from err
except ValueError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_endpoint_url",
) from err
except ConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = client
def notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
"""Unload a config entry."""
client = entry.runtime_data
await client.__aexit__(None, None, None)
return True

View File

@ -0,0 +1,330 @@
"""Backup platform for the AWS S3 integration."""
from collections.abc import AsyncIterator, Callable, Coroutine
import functools
import json
import logging
from time import time
from typing import Any
from botocore.exceptions import BotoCoreError
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from . import S3ConfigEntry
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
CACHE_TTL = 300
# S3 part size requirements: 5 MiB to 5 GiB per part
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
# We set the threshold to 20 MiB to avoid too many parts.
# Note that each part is allocated in the memory.
MULTIPART_MIN_PART_SIZE_BYTES = 20 * 2**20
def handle_boto_errors[T](
func: Callable[..., Coroutine[Any, Any, T]],
) -> Callable[..., Coroutine[Any, Any, T]]:
"""Handle BotoCoreError exceptions by converting them to BackupAgentError."""
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> T:
"""Catch BotoCoreError and raise BackupAgentError."""
try:
return await func(*args, **kwargs)
except BotoCoreError as err:
error_msg = f"Failed during {func.__name__}"
raise BackupAgentError(error_msg) from err
return wrapper
async def async_get_backup_agents(
hass: HomeAssistant,
) -> list[BackupAgent]:
"""Return a list of backup agents."""
entries: list[S3ConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
return [S3BackupAgent(hass, entry) for entry in entries]
@callback
def async_register_backup_agents_listener(
hass: HomeAssistant,
*,
listener: Callable[[], None],
**kwargs: Any,
) -> Callable[[], None]:
"""Register a listener to be called when agents are added or removed.
:return: A function to unregister the listener.
"""
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
@callback
def remove_listener() -> None:
"""Remove the listener."""
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
return remove_listener
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata files."""
base_name = suggested_filename(backup).rsplit(".", 1)[0]
return f"{base_name}.tar", f"{base_name}.metadata.json"
class S3BackupAgent(BackupAgent):
"""Backup agent for the S3 integration."""
domain = DOMAIN
def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None:
"""Initialize the S3 agent."""
super().__init__()
self._client = entry.runtime_data
self._bucket: str = entry.data[CONF_BUCKET]
self.name = entry.title
self.unique_id = entry.entry_id
self._backup_cache: dict[str, AgentBackup] = {}
self._cache_expiration = time()
@handle_boto_errors
async def async_download_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file.
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
backup = await self._find_backup_by_id(backup_id)
tar_filename, _ = suggested_filenames(backup)
response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename)
return response["Body"].iter_chunks()
async def async_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param backup: Metadata about the backup that should be uploaded.
"""
tar_filename, metadata_filename = suggested_filenames(backup)
try:
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream)
else:
await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file
metadata_content = json.dumps(backup.as_dict())
await self._client.put_object(
Bucket=self._bucket,
Key=metadata_filename,
Body=metadata_content,
)
except BotoCoreError as err:
raise BackupAgentError("Failed to upload backup") from err
else:
# Reset cache after successful upload
self._cache_expiration = time()
async def _upload_simple(
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
) -> None:
"""Upload a small file using simple upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
"""
_LOGGER.debug("Starting simple upload for %s", tar_filename)
stream = await open_stream()
file_data = bytearray()
async for chunk in stream:
file_data.extend(chunk)
await self._client.put_object(
Bucket=self._bucket,
Key=tar_filename,
Body=bytes(file_data),
)
async def _upload_multipart(
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
):
"""Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
"""
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await self._client.create_multipart_upload(
Bucket=self._bucket,
Key=tar_filename,
)
upload_id = multipart_upload["UploadId"]
try:
parts = []
part_number = 1
buffer_size = 0 # bytes
buffer: list[bytes] = []
stream = await open_stream()
async for chunk in stream:
buffer_size += len(chunk)
buffer.append(chunk)
# If buffer size meets minimum part size, upload it as a part
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
_LOGGER.debug(
"Uploading part number %d, size %d", part_number, buffer_size
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=tar_filename,
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
part_number += 1
buffer_size = 0
buffer = []
# Upload the final buffer as the last part (no minimum size requirement)
if buffer:
_LOGGER.debug(
"Uploading final part number %d, size %d", part_number, buffer_size
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=tar_filename,
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
await self._client.complete_multipart_upload(
Bucket=self._bucket,
Key=tar_filename,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
except BotoCoreError:
try:
await self._client.abort_multipart_upload(
Bucket=self._bucket,
Key=tar_filename,
UploadId=upload_id,
)
except BotoCoreError:
_LOGGER.exception("Failed to abort multipart upload")
raise
@handle_boto_errors
async def async_delete_backup(
self,
backup_id: str,
**kwargs: Any,
) -> None:
"""Delete a backup file.
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
backup = await self._find_backup_by_id(backup_id)
tar_filename, metadata_filename = suggested_filenames(backup)
# Delete both the backup file and its metadata file
await self._client.delete_object(Bucket=self._bucket, Key=tar_filename)
await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename)
# Reset cache after successful deletion
self._cache_expiration = time()
@handle_boto_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
backups = await self._list_backups()
return list(backups.values())
@handle_boto_errors
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
"""Return a backup."""
return await self._find_backup_by_id(backup_id)
async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
"""Find a backup by its backup ID."""
backups = await self._list_backups()
if backup := backups.get(backup_id):
return backup
raise BackupNotFound(f"Backup {backup_id} not found")
async def _list_backups(self) -> dict[str, AgentBackup]:
"""List backups, using a cache if possible."""
if time() <= self._cache_expiration:
return self._backup_cache
backups = {}
response = await self._client.list_objects_v2(Bucket=self._bucket)
# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
for metadata_file in metadata_files:
try:
# Download and parse metadata file
metadata_response = await self._client.get_object(
Bucket=self._bucket, Key=metadata_file["Key"]
)
metadata_content = await metadata_response["Body"].read()
metadata_json = json.loads(metadata_content)
except (BotoCoreError, json.JSONDecodeError) as err:
_LOGGER.warning(
"Failed to process metadata file %s: %s",
metadata_file["Key"],
err,
)
continue
backup = AgentBackup.from_dict(metadata_json)
backups[backup.backup_id] = backup
self._backup_cache = backups
self._cache_expiration = time() + CACHE_TTL
return self._backup_cache

View File

@ -0,0 +1,101 @@
"""Config flow for the AWS S3 integration."""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
from aiobotocore.session import AioSession
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import (
AWS_DOMAIN,
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_SECRET_ACCESS_KEY,
DEFAULT_ENDPOINT_URL,
DESCRIPTION_AWS_S3_DOCS_URL,
DESCRIPTION_BOTO3_DOCS_URL,
DOMAIN,
)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACCESS_KEY_ID): cv.string,
vol.Required(CONF_SECRET_ACCESS_KEY): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Required(CONF_BUCKET): cv.string,
vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.URL)
),
}
)
class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_BUCKET: user_input[CONF_BUCKET],
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
}
)
if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith(
AWS_DOMAIN
):
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
else:
try:
session = AioSession()
async with session.create_client(
"s3",
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
) as client:
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
except ClientError:
errors["base"] = "invalid_credentials"
except ParamValidationError as err:
if "Invalid bucket name" in str(err):
errors[CONF_BUCKET] = "invalid_bucket_name"
except ValueError:
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
except ConnectionError:
errors[CONF_ENDPOINT_URL] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_BUCKET], data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
description_placeholders={
"aws_s3_docs_url": DESCRIPTION_AWS_S3_DOCS_URL,
"boto3_docs_url": DESCRIPTION_BOTO3_DOCS_URL,
},
)

View File

@ -0,0 +1,23 @@
"""Constants for the AWS S3 integration."""
from collections.abc import Callable
from typing import Final
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "aws_s3"
CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
AWS_DOMAIN = "amazonaws.com"
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)
DESCRIPTION_AWS_S3_DOCS_URL = "https://docs.aws.amazon.com/general/latest/gr/s3.html"
DESCRIPTION_BOTO3_DOCS_URL = "https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html"

View File

@ -0,0 +1,12 @@
{
"domain": "aws_s3",
"name": "AWS S3",
"codeowners": ["@tomasbedrich"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aws_s3",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["aiobotocore"],
"quality_scale": "bronze",
"requirements": ["aiobotocore==2.21.1"]
}

View File

@ -0,0 +1,112 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: exempt
comment: This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not have any custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities of this integration does not explicitly subscribe to events.
entity-unique-id:
status: exempt
comment: This integration does not have entities.
has-entity-name:
status: exempt
comment: This integration does not have entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: This integration does not have entities.
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: exempt
comment: This integration does not poll.
reauthentication-flow: todo
test-coverage: done
# Gold
devices:
status: exempt
comment: This integration does not have entities.
diagnostics: todo
discovery-update-info:
status: exempt
comment: S3 is a cloud service that is not discovered on the network.
discovery:
status: exempt
comment: S3 is a cloud service that is not discovered on the network.
docs-data-update:
status: exempt
comment: This integration does not poll.
docs-examples:
status: exempt
comment: The integration extends core functionality and does not require examples.
docs-known-limitations:
status: exempt
comment: No known limitations.
docs-supported-devices:
status: exempt
comment: This integration does not support physical devices.
docs-supported-functions: done
docs-troubleshooting:
status: exempt
comment: There are no more detailed troubleshooting instructions available than what is already included in strings.json.
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This integration does not have devices.
entity-category:
status: exempt
comment: This integration does not have entities.
entity-device-class:
status: exempt
comment: This integration does not have entities.
entity-disabled-by-default:
status: exempt
comment: This integration does not have entities.
entity-translations:
status: exempt
comment: This integration does not have entities.
exception-translations: done
icon-translations:
status: exempt
comment: This integration does not use icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: There are no issues which can be repaired.
stale-devices:
status: exempt
comment: This integration does not have devices.
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@ -0,0 +1,41 @@
{
"config": {
"step": {
"user": {
"data": {
"access_key_id": "Access key ID",
"secret_access_key": "Secret access key",
"bucket": "Bucket name",
"endpoint_url": "Endpoint URL"
},
"data_description": {
"access_key_id": "Access key ID to connect to AWS S3 API",
"secret_access_key": "Secret access key to connect to AWS S3 API",
"bucket": "Bucket must already exist and be writable by the provided credentials.",
"endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs."
},
"title": "Add AWS S3 bucket"
}
},
"error": {
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::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."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "Cannot connect to endpoint"
},
"invalid_bucket_name": {
"message": "Invalid bucket name"
},
"invalid_credentials": {
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
}
}
}

View File

@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from ipaddress import ip_address from ipaddress import ip_address
from types import MappingProxyType
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
api = await get_axis_api(self.hass, MappingProxyType(user_input)) api = await get_axis_api(self.hass, user_input)
except AuthenticationRequired: except AuthenticationRequired:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"

View File

@ -1,7 +1,7 @@
"""Axis network device abstraction.""" """Axis network device abstraction."""
from asyncio import timeout from asyncio import timeout
from types import MappingProxyType from collections.abc import Mapping
from typing import Any from typing import Any
import axis import axis
@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect
async def get_axis_api( async def get_axis_api(
hass: HomeAssistant, hass: HomeAssistant,
config: MappingProxyType[str, Any], config: Mapping[str, Any],
) -> axis.AxisDevice: ) -> axis.AxisDevice:
"""Create a Axis device API.""" """Create a Axis device API."""
session = get_async_client(hass, verify_ssl=False) session = get_async_client(hass, verify_ssl=False)

View File

@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable, Mapping
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from azure.eventhub import EventData, EventDataBatch from azure.eventhub import EventData, EventDataBatch
@ -179,7 +178,7 @@ class AzureEventHub:
await self.async_send(None) await self.async_send(None)
await self._queue.join() await self._queue.join()
def update_options(self, new_options: MappingProxyType[str, Any]) -> None: def update_options(self, new_options: Mapping[str, Any]) -> None:
"""Update options.""" """Update options."""
self._send_interval = new_options[CONF_SEND_INTERVAL] self._send_interval = new_options[CONF_SEND_INTERVAL]

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
import datetime as dt import datetime as dt
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -87,12 +88,26 @@ class BackupConfigData:
else: else:
time = None time = None
days = [Day(day) for day in data["schedule"]["days"]] days = [Day(day) for day in data["schedule"]["days"]]
agents = {}
for agent_id, agent_data in data["agents"].items():
protected = agent_data["protected"]
stored_retention = agent_data["retention"]
agent_retention: AgentRetentionConfig | None
if stored_retention:
agent_retention = AgentRetentionConfig(
copies=stored_retention["copies"],
days=stored_retention["days"],
)
else:
agent_retention = None
agent_config = AgentConfig(
protected=protected,
retention=agent_retention,
)
agents[agent_id] = agent_config
return cls( return cls(
agents={ agents=agents,
agent_id: AgentConfig(protected=agent_data["protected"])
for agent_id, agent_data in data["agents"].items()
},
automatic_backups_configured=data["automatic_backups_configured"], automatic_backups_configured=data["automatic_backups_configured"],
create_backup=CreateBackupConfig( create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"], agent_ids=data["create_backup"]["agent_ids"],
@ -176,12 +191,36 @@ class BackupConfig:
"""Update config.""" """Update config."""
if agents is not UNDEFINED: if agents is not UNDEFINED:
for agent_id, agent_config in agents.items(): for agent_id, agent_config in agents.items():
if agent_id not in self.data.agents: agent_retention = agent_config.get("retention")
self.data.agents[agent_id] = AgentConfig(**agent_config) if agent_retention is None:
new_agent_retention = None
else: else:
self.data.agents[agent_id] = replace( new_agent_retention = AgentRetentionConfig(
self.data.agents[agent_id], **agent_config copies=agent_retention.get("copies"),
days=agent_retention.get("days"),
) )
if agent_id not in self.data.agents:
old_agent_retention = None
self.data.agents[agent_id] = AgentConfig(
protected=agent_config.get("protected", True),
retention=new_agent_retention,
)
else:
new_agent_config = self.data.agents[agent_id]
old_agent_retention = new_agent_config.retention
if "protected" in agent_config:
new_agent_config = replace(
new_agent_config, protected=agent_config["protected"]
)
if "retention" in agent_config:
new_agent_config = replace(
new_agent_config, retention=new_agent_retention
)
self.data.agents[agent_id] = new_agent_config
if new_agent_retention != old_agent_retention:
# There's a single retention application method
# for both global and agent retention settings.
self.data.retention.apply(self._manager)
if automatic_backups_configured is not UNDEFINED: if automatic_backups_configured is not UNDEFINED:
self.data.automatic_backups_configured = automatic_backups_configured self.data.automatic_backups_configured = automatic_backups_configured
if create_backup is not UNDEFINED: if create_backup is not UNDEFINED:
@ -207,11 +246,24 @@ class AgentConfig:
"""Represent the config for an agent.""" """Represent the config for an agent."""
protected: bool protected: bool
"""Agent protected configuration.
If True, the agent backups are password protected.
"""
retention: AgentRetentionConfig | None = None
"""Agent retention configuration.
If None, the global retention configuration is used.
If not None, the global retention configuration is ignored for this agent.
If an agent retention configuration is set and both copies and days are None,
backups will be kept forever for that agent.
"""
def to_dict(self) -> StoredAgentConfig: def to_dict(self) -> StoredAgentConfig:
"""Convert agent config to a dict.""" """Convert agent config to a dict."""
return { return {
"protected": self.protected, "protected": self.protected,
"retention": self.retention.to_dict() if self.retention else None,
} }
@ -219,24 +271,46 @@ class StoredAgentConfig(TypedDict):
"""Represent the stored config for an agent.""" """Represent the stored config for an agent."""
protected: bool protected: bool
retention: StoredRetentionConfig | None
class AgentParametersDict(TypedDict, total=False): class AgentParametersDict(TypedDict, total=False):
"""Represent the parameters for an agent.""" """Represent the parameters for an agent."""
protected: bool protected: bool
retention: RetentionParametersDict | None
@dataclass(kw_only=True) @dataclass(kw_only=True)
class RetentionConfig: class BaseRetentionConfig:
"""Represent the backup retention configuration.""" """Represent the base backup retention configuration."""
copies: int | None = None copies: int | None = None
days: int | None = None days: int | None = None
def to_dict(self) -> StoredRetentionConfig:
"""Convert backup retention configuration to a dict."""
return StoredRetentionConfig(
copies=self.copies,
days=self.days,
)
@dataclass(kw_only=True)
class RetentionConfig(BaseRetentionConfig):
"""Represent the backup retention configuration."""
def apply(self, manager: BackupManager) -> None: def apply(self, manager: BackupManager) -> None:
"""Apply backup retention configuration.""" """Apply backup retention configuration."""
if self.days is not None: agents_retention = {
agent_id: agent_config.retention
for agent_id, agent_config in manager.config.data.agents.items()
}
if self.days is not None or any(
agent_retention and agent_retention.days is not None
for agent_retention in agents_retention.values()
):
LOGGER.debug( LOGGER.debug(
"Scheduling next automatic delete of backups older than %s in 1 day", "Scheduling next automatic delete of backups older than %s in 1 day",
self.days, self.days,
@ -246,13 +320,6 @@ class RetentionConfig:
LOGGER.debug("Unscheduling next automatic delete") LOGGER.debug("Unscheduling next automatic delete")
self._unschedule_next(manager) self._unschedule_next(manager)
def to_dict(self) -> StoredRetentionConfig:
"""Convert backup retention configuration to a dict."""
return StoredRetentionConfig(
copies=self.copies,
days=self.days,
)
@callback @callback
def _schedule_next( def _schedule_next(
self, self,
@ -271,16 +338,81 @@ class RetentionConfig:
"""Return backups older than days to delete.""" """Return backups older than days to delete."""
# we need to check here since we await before # we need to check here since we await before
# this filter is applied # this filter is applied
if self.days is None: agents_retention = {
return {} agent_id: agent_config.retention
now = dt_util.utcnow() for agent_id, agent_config in manager.config.data.agents.items()
return {
backup_id: backup
for backup_id, backup in backups.items()
if dt_util.parse_datetime(backup.date, raise_on_error=True)
+ timedelta(days=self.days)
< now
} }
has_agents_retention = any(
agent_retention for agent_retention in agents_retention.values()
)
has_agents_retention_days = any(
agent_retention and agent_retention.days is not None
for agent_retention in agents_retention.values()
)
if (global_days := self.days) is None and not has_agents_retention_days:
# No global retention days and no agent retention days
return {}
now = dt_util.utcnow()
if global_days is not None and not has_agents_retention:
# Return early to avoid the longer filtering below.
return {
backup_id: backup
for backup_id, backup in backups.items()
if dt_util.parse_datetime(backup.date, raise_on_error=True)
+ timedelta(days=global_days)
< now
}
# If there are any agent retention settings, we need to check
# the retention settings, for every backup and agent combination.
backups_to_delete = {}
for backup_id, backup in backups.items():
backup_date = dt_util.parse_datetime(
backup.date, raise_on_error=True
)
delete_from_agents = set(backup.agents)
for agent_id in backup.agents:
agent_retention = agents_retention.get(agent_id)
if agent_retention is None:
# This agent does not have a retention setting,
# so the global retention setting should be used.
if global_days is None:
# This agent does not have a retention setting
# and the global retention days setting is None,
# so this backup should not be deleted.
delete_from_agents.discard(agent_id)
continue
days = global_days
elif (agent_days := agent_retention.days) is None:
# This agent has a retention setting
# where days is set to None,
# so the backup should not be deleted.
delete_from_agents.discard(agent_id)
continue
else:
# This agent has a retention setting
# where days is set to a number,
# so that setting should be used.
days = agent_days
if backup_date + timedelta(days=days) >= now:
# This backup is not older than the retention days,
# so this agent should not be deleted.
delete_from_agents.discard(agent_id)
filtered_backup = replace(
backup,
agents={
agent_id: agent_backup_status
for agent_id, agent_backup_status in backup.agents.items()
if agent_id in delete_from_agents
},
)
backups_to_delete[backup_id] = filtered_backup
return backups_to_delete
await manager.async_delete_filtered_backups( await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter include_filter=_automatic_backups_filter, delete_filter=_delete_filter
@ -312,6 +444,10 @@ class RetentionParametersDict(TypedDict, total=False):
days: int | None days: int | None
class AgentRetentionConfig(BaseRetentionConfig):
"""Represent an agent retention configuration."""
class StoredBackupSchedule(TypedDict): class StoredBackupSchedule(TypedDict):
"""Represent the stored backup schedule configuration.""" """Represent the stored backup schedule configuration."""
@ -554,16 +690,87 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
backups: dict[str, ManagerBackup], backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]: ) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete.""" """Return oldest backups more numerous than copies to delete."""
agents_retention = {
agent_id: agent_config.retention
for agent_id, agent_config in manager.config.data.agents.items()
}
has_agents_retention = any(
agent_retention for agent_retention in agents_retention.values()
)
has_agents_retention_copies = any(
agent_retention and agent_retention.copies is not None
for agent_retention in agents_retention.values()
)
# we need to check here since we await before # we need to check here since we await before
# this filter is applied # this filter is applied
if manager.config.data.retention.copies is None: if (
global_copies := manager.config.data.retention.copies
) is None and not has_agents_retention_copies:
# No global retention copies and no agent retention copies
return {} return {}
return dict( if global_copies is not None and not has_agents_retention:
sorted( # Return early to avoid the longer filtering below.
backups.items(), return dict(
key=lambda backup_item: backup_item[1].date, sorted(
)[: max(len(backups) - manager.config.data.retention.copies, 0)] backups.items(),
key=lambda backup_item: backup_item[1].date,
)[: max(len(backups) - global_copies, 0)]
)
backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict)
for backup_id, backup in backups.items():
for agent_id in backup.agents:
backups_by_agent[agent_id][backup_id] = backup
backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(
dict
) )
for agent_id, agent_backups in backups_by_agent.items():
agent_retention = agents_retention.get(agent_id)
if agent_retention is None:
# This agent does not have a retention setting,
# so the global retention setting should be used.
if global_copies is None:
# This agent does not have a retention setting
# and the global retention copies setting is None,
# so backups should not be deleted.
continue
# The global retention setting will be used.
copies = global_copies
elif (agent_copies := agent_retention.copies) is None:
# This agent has a retention setting
# where copies is set to None,
# so backups should not be deleted.
continue
else:
# This agent retention setting will be used.
copies = agent_copies
backups_to_delete_by_agent[agent_id] = dict(
sorted(
agent_backups.items(),
key=lambda backup_item: backup_item[1].date,
)[: max(len(agent_backups) - copies, 0)]
)
backup_ids_to_delete: dict[str, set[str]] = defaultdict(set)
for agent_id, to_delete in backups_to_delete_by_agent.items():
for backup_id in to_delete:
backup_ids_to_delete[backup_id].add(agent_id)
backups_to_delete: dict[str, ManagerBackup] = {}
for backup_id, agent_ids in backup_ids_to_delete.items():
backup = backups[backup_id]
# filter the backup to only include the agents that should be deleted
filtered_backup = replace(
backup,
agents={
agent_id: agent_backup_status
for agent_id, agent_backup_status in backup.agents.items()
if agent_id in agent_ids
},
)
backups_to_delete[backup_id] = filtered_backup
return backups_to_delete
await manager.async_delete_filtered_backups( await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter include_filter=_automatic_backups_filter, delete_filter=_delete_filter

View File

@ -0,0 +1,136 @@
"""Backup onboarding views."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from functools import wraps
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, Concatenate
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.onboarding import (
BaseOnboardingView,
NoAuthBaseOnboardingView,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData
async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the backup views."""
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, BackupManager, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
"""Home Assistant API decorator to check onboarding and inject manager."""
@wraps(func)
async def with_backup(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check admin and call function."""
if self._data["done"]:
raise HTTPUnauthorized
manager = await async_get_backup_manager(request.app[KEY_HASS])
return await func(self, manager, request, *args, **kwargs)
return with_backup
class BackupInfoView(NoAuthBaseOnboardingView):
"""Get backup info view."""
url = "/api/onboarding/backup/info"
name = "api:onboarding:backup:info"
@with_backup_manager
async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Return backup info."""
backups, _ = await manager.async_get_backups()
return self.json(
{
"backups": list(backups.values()),
"state": manager.state,
"last_action_event": manager.last_action_event,
}
)
class RestoreBackupView(NoAuthBaseOnboardingView):
"""Restore backup view."""
url = "/api/onboarding/backup/restore"
name = "api:onboarding:backup:restore"
@RequestDataValidator(
vol.Schema(
{
vol.Required("backup_id"): str,
vol.Required("agent_id"): str,
vol.Optional("password"): str,
vol.Optional("restore_addons"): [str],
vol.Optional("restore_database", default=True): bool,
vol.Optional("restore_folders"): [vol.Coerce(Folder)],
}
)
)
@with_backup_manager
async def post(
self, manager: BackupManager, request: web.Request, data: dict[str, Any]
) -> web.Response:
"""Restore a backup."""
try:
await manager.async_restore_backup(
data["backup_id"],
agent_id=data["agent_id"],
password=data.get("password"),
restore_addons=data.get("restore_addons"),
restore_database=data["restore_database"],
restore_folders=data.get("restore_folders"),
restore_homeassistant=True,
)
except IncorrectPasswordError:
return self.json(
{"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
)
except HomeAssistantError as err:
return self.json(
{"code": "restore_failed", "message": str(err)},
status_code=HTTPStatus.BAD_REQUEST,
)
return web.Response(status=HTTPStatus.OK)
class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView):
"""Upload backup view."""
url = "/api/onboarding/backup/upload"
name = "api:onboarding:backup:upload"
@with_backup_manager
async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Upload a backup file."""
return await self._post(request)

View File

@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30 STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 5 STORAGE_VERSION_MINOR = 6
class StoredBackupData(TypedDict): class StoredBackupData(TypedDict):
@ -72,6 +72,10 @@ class _BackupStore(Store[StoredBackupData]):
data["config"]["automatic_backups_configured"] = ( data["config"]["automatic_backups_configured"] = (
data["config"]["create_backup"]["password"] is not None data["config"]["create_backup"]["password"] is not None
) )
if old_minor_version < 6:
# Version 1.6 adds agent retention settings
for agent in data["config"]["agents"]:
data["config"]["agents"][agent]["retention"] = None
# Note: We allow reading data with major version 2. # Note: We allow reading data with major version 2.
# Reject if major version is higher than 2. # Reject if major version is higher than 2.

View File

@ -26,9 +26,9 @@
"entity": { "entity": {
"sensor": { "sensor": {
"backup_manager_state": { "backup_manager_state": {
"name": "Backup Manager State", "name": "Backup Manager state",
"state": { "state": {
"idle": "Idle", "idle": "[%key:common::state::idle%]",
"create_backup": "Creating a backup", "create_backup": "Creating a backup",
"receive_backup": "Receiving a backup", "receive_backup": "Receiving a backup",
"restore_backup": "Restoring a backup" "restore_backup": "Restoring a backup"

View File

@ -346,7 +346,28 @@ async def handle_config_info(
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "backup/config/update", vol.Required("type"): "backup/config/update",
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), vol.Optional("agents"): vol.Schema(
{
str: {
vol.Optional("protected"): bool,
vol.Optional("retention"): vol.Any(
vol.Schema(
{
# Note: We can't use cv.positive_int because it allows 0 even
# though 0 is not positive.
vol.Optional("copies"): vol.Any(
vol.All(int, vol.Range(min=1)), None
),
vol.Optional("days"): vol.Any(
vol.All(int, vol.Range(min=1)), None
),
},
),
None,
),
}
}
),
vol.Optional("automatic_backups_configured"): bool, vol.Optional("automatic_backups_configured"): bool,
vol.Optional("create_backup"): vol.Schema( vol.Optional("create_backup"): vol.Schema(
{ {

View File

@ -31,7 +31,7 @@
"state_attributes": { "state_attributes": {
"preset_mode": { "preset_mode": {
"state": { "state": {
"auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" "auto": "[%key:common::state::auto%]"
} }
} }
} }

View File

@ -0,0 +1 @@
"""Balay virtual integration."""

View File

@ -0,0 +1,6 @@
{
"domain": "balay",
"name": "Balay",
"integration_type": "virtual",
"supported_by": "home_connect"
}

View File

@ -103,8 +103,8 @@
"temperature_range": { "temperature_range": {
"name": "Temperature range", "name": "Temperature range",
"state": { "state": {
"low": "Low", "low": "[%key:common::state::low%]",
"high": "High" "high": "[%key:common::state::high%]"
} }
} }
}, },

View File

@ -124,15 +124,15 @@
"battery": { "battery": {
"name": "Battery", "name": "Battery",
"state": { "state": {
"off": "Normal", "off": "[%key:common::state::normal%]",
"on": "Low" "on": "[%key:common::state::low%]"
} }
}, },
"battery_charging": { "battery_charging": {
"name": "Charging", "name": "Charging",
"state": { "state": {
"off": "Not charging", "off": "Not charging",
"on": "Charging" "on": "[%key:common::state::charging%]"
} }
}, },
"carbon_monoxide": { "carbon_monoxide": {
@ -145,7 +145,7 @@
"cold": { "cold": {
"name": "Cold", "name": "Cold",
"state": { "state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "off": "[%key:common::state::normal%]",
"on": "Cold" "on": "Cold"
} }
}, },
@ -180,7 +180,7 @@
"heat": { "heat": {
"name": "Heat", "name": "Heat",
"state": { "state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "off": "[%key:common::state::normal%]",
"on": "Hot" "on": "Hot"
} }
}, },

View File

@ -30,18 +30,18 @@
"available": "Available", "available": "Available",
"charging": "[%key:common::state::charging%]", "charging": "[%key:common::state::charging%]",
"unavailable": "Unavailable", "unavailable": "Unavailable",
"error": "Error", "error": "[%key:common::state::error%]",
"offline": "Offline" "offline": "Offline"
} }
}, },
"vehicle_status": { "vehicle_status": {
"name": "Vehicle status", "name": "Vehicle status",
"state": { "state": {
"standby": "Standby", "standby": "[%key:common::state::standby%]",
"vehicle_detected": "Detected", "vehicle_detected": "Detected",
"ready": "Ready", "ready": "Ready",
"no_power": "No power", "no_power": "No power",
"vehicle_error": "Error" "vehicle_error": "[%key:common::state::error%]"
} }
}, },
"actual_v1": { "actual_v1": {

View File

@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bluemaestro", "documentation": "https://www.home-assistant.io/integrations/bluemaestro",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bluemaestro-ble==0.2.3"] "requirements": ["bluemaestro-ble==0.4.1"]
} }

View File

@ -6,7 +6,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pyblu==2.0.0"], "requirements": ["pyblu==2.0.1"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_musc._tcp.local." "type": "_musc._tcp.local."

View File

@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self._status.input_id is not None: if self._status.input_id is not None:
for input_ in self._inputs: for input_ in self._inputs:
if input_.id == self._status.input_id: # the input might not have an id => also try to match on the stream_url/url
# we have to use both because neither matches all the time
if (
input_.id == self._status.input_id
or input_.url == self._status.stream_url
):
return input_.text return input_.text
for preset in self._presets: for preset in self._presets:

View File

@ -18,9 +18,9 @@
"bleak==0.22.3", "bleak==0.22.3",
"bleak-retry-connector==3.9.0", "bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4", "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5", "bluetooth-auto-recovery==1.5.1",
"bluetooth-data-tools==1.26.5", "bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0", "dbus-fast==2.43.0",
"habluetooth==3.37.0" "habluetooth==3.48.2"
] ]
} }

View File

@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
self.logger.exception("Unexpected error updating %s data", self.name) self.logger.exception("Unexpected error updating %s data", self.name)
return return
self._process_update(update, was_available)
@callback
def async_set_updated_data(self, update: _DataT) -> None:
"""Manually update the processor with new data.
If the data comes in via a different method, like a
notification, this method can be used to update the
processor with the new data.
This is useful for devices that retrieve
some of their data via notifications.
"""
was_available = self._available
self._available = True
self._process_update(update, was_available)
def _process_update(
self, update: _DataT, was_available: bool | None = None
) -> None:
"""Process the update from the bluetooth device."""
if not self.last_update_success: if not self.last_update_success:
self.last_update_success = True self.last_update_success = True
self.logger.info("Coordinator %s recovered", self.name) self.logger.info("Coordinator %s recovered", self.name)

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from bluetooth_adapters import ( from habluetooth import (
DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementData,
DiscoveredDeviceAdvertisementDataDict, DiscoveredDeviceAdvertisementDataDict,
DiscoveryStorageType, DiscoveryStorageType,

View File

@ -6,7 +6,7 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive Region" "region": "ConnectedDrive region"
}, },
"data_description": { "data_description": {
"username": "The email address of your MyBMW/MINI Connected account.", "username": "The email address of your MyBMW/MINI Connected account.",
@ -113,10 +113,10 @@
}, },
"select": { "select": {
"ac_limit": { "ac_limit": {
"name": "AC Charging Limit" "name": "AC charging limit"
}, },
"charging_mode": { "charging_mode": {
"name": "Charging Mode", "name": "Charging mode",
"state": { "state": {
"immediate_charging": "Immediate charging", "immediate_charging": "Immediate charging",
"delayed_charging": "Delayed charging", "delayed_charging": "Delayed charging",
@ -139,7 +139,7 @@
"state": { "state": {
"default": "Default", "default": "Default",
"charging": "[%key:common::state::charging%]", "charging": "[%key:common::state::charging%]",
"error": "Error", "error": "[%key:common::state::error%]",
"complete": "Complete", "complete": "Complete",
"fully_charged": "Fully charged", "fully_charged": "Fully charged",
"finished_fully_charged": "Finished, fully charged", "finished_fully_charged": "Finished, fully charged",
@ -181,7 +181,7 @@
"cooling": "Cooling", "cooling": "Cooling",
"heating": "Heating", "heating": "Heating",
"inactive": "Inactive", "inactive": "Inactive",
"standby": "Standby", "standby": "[%key:common::state::standby%]",
"ventilation": "Ventilation" "ventilation": "Ventilation"
} }
}, },

View File

@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN from .const import DOMAIN
@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_ACCESS_TOKEN] = token self._discovered[CONF_ACCESS_TOKEN] = token
try: try:
_, hub_name = await _validate_input(self.hass, self._discovered) bond_id, hub_name = await _validate_input(self.hass, self._discovered)
except InputValidationError: except InputValidationError:
return return
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered[CONF_NAME] = hub_name self._discovered[CONF_NAME] = hub_name
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by dhcp discovery."""
host = discovery_info.ip
bond_id = discovery_info.hostname.partition("-")[2].upper()
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_zeroconf( async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = discovery_info.host host: str = discovery_info.host
bond_id = name.partition(".")[0] bond_id = name.partition(".")[0]
await self.async_set_unique_id(bond_id) await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_any_discovery(
self, bond_id: str, host: str
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
for entry in self._async_current_entries(): for entry in self._async_current_entries():
if entry.unique_id != bond_id: if entry.unique_id != bond_id:
continue continue
updates = {CONF_HOST: host} updates = {CONF_HOST: host}
if entry.state == ConfigEntryState.SETUP_ERROR and ( if entry.state is ConfigEntryState.SETUP_ERROR and (
token := await async_get_token(self.hass, host) token := await async_get_token(self.hass, host)
): ):
updates[CONF_ACCESS_TOKEN] = token updates[CONF_ACCESS_TOKEN] = token
@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._discovered[CONF_HOST], CONF_HOST: self._discovered[CONF_HOST],
} }
try: try:
_, hub_name = await _validate_input(self.hass, data) bond_id, hub_name = await _validate_input(self.hass, data)
except InputValidationError as error: except InputValidationError as error:
errors["base"] = error.base errors["base"] = error.base
else: else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._discovered[CONF_HOST]}
)
return self.async_create_entry( return self.async_create_entry(
title=hub_name, title=hub_name,
data=data, data=data,
@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
except InputValidationError as error: except InputValidationError as error:
errors["base"] = error.base errors["base"] = error.base
else: else:
await self.async_set_unique_id(bond_id) await self.async_set_unique_id(bond_id, raise_on_progress=False)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry(title=hub_name, data=user_input) return self.async_create_entry(title=hub_name, data=user_input)
return self.async_show_form( return self.async_show_form(

View File

@ -3,6 +3,16 @@
"name": "Bond", "name": "Bond",
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
"config_flow": true, "config_flow": true,
"dhcp": [
{
"hostname": "bond-*",
"macaddress": "3C6A2C1*"
},
{
"hostname": "bond-*",
"macaddress": "F44E38*"
}
],
"documentation": "https://www.home-assistant.io/integrations/bond", "documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["bond_async"], "loggers": ["bond_async"],

View File

@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
type BoschAlarmConfigEntry = ConfigEntry[Panel] type BoschAlarmConfigEntry = ConfigEntry[Panel]
@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
await panel.connect() await panel.connect()
except (PermissionError, ValueError) as err: except (PermissionError, ValueError) as err:
await panel.disconnect() await panel.disconnect()
raise ConfigEntryNotReady from err raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
await panel.disconnect() await panel.disconnect()
raise ConfigEntryNotReady("Connection failed") from err raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = panel entry.runtime_data = panel

View File

@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState, AlarmControlPanelState,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry from . import BoschAlarmConfigEntry
from .const import DOMAIN from .entity import BoschAlarmAreaEntity
async def async_setup_entry( async def async_setup_entry(
@ -35,7 +34,7 @@ async def async_setup_entry(
) )
class AreaAlarmControlPanel(AlarmControlPanelEntity): class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
"""An alarm control panel entity for a bosch alarm panel.""" """An alarm control panel entity for a bosch alarm panel."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity.""" """Initialise a Bosch Alarm control panel entity."""
self.panel = panel super().__init__(panel, area_id, unique_id, False, False, True)
self._area = panel.areas[area_id] self._attr_unique_id = self._area_unique_id
self._area_id = area_id
self._attr_unique_id = f"{unique_id}_area_{area_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(
DOMAIN,
unique_id,
),
)
@property @property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:
@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command.""" """Send arm away command."""
await self.panel.area_arm_all(self._area_id) await self.panel.area_arm_all(self._area_id)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Run when entity attached to hass."""
await super().async_added_to_hass()
self._area.status_observer.attach(self.schedule_update_ha_state)
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity removed from hass."""
await super().async_will_remove_from_hass()
self._area.status_observer.detach(self.schedule_update_ha_state)
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
import logging import logging
import ssl import ssl
from typing import Any from typing import Any
@ -10,7 +11,12 @@ from typing import Any
from bosch_alarm_mode2 import Panel from bosch_alarm_mode2 import Panel
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_CODE, CONF_CODE,
CONF_HOST, CONF_HOST,
@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
self._data = user_input self._data = user_input
self._data[CONF_MODEL] = model self._data[CONF_MODEL] = model
if self.source == SOURCE_RECONFIGURE:
if (
self._get_reconfigure_entry().data[CONF_MODEL]
!= self._data[CONF_MODEL]
):
return self.async_abort(reason="device_mismatch")
return await self.async_step_auth() return await self.async_step_auth()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfigure step."""
return await self.async_step_user()
async def async_step_auth( async def async_step_auth(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
if serial_number: if serial_number:
await self.async_set_unique_id(str(serial_number)) await self.async_set_unique_id(str(serial_number))
self._abort_if_unique_id_configured() if self.source == SOURCE_USER:
else: if serial_number:
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) self._abort_if_unique_id_configured()
return self.async_create_entry(title=f"Bosch {model}", data=self._data) else:
self._async_abort_entries_match(
{CONF_HOST: self._data[CONF_HOST]}
)
return self.async_create_entry(
title=f"Bosch {model}", data=self._data
)
if serial_number:
self._abort_if_unique_id_mismatch(reason="device_mismatch")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data=self._data,
)
return self.async_show_form( return self.async_show_form(
step_id="auth", step_id="auth",
data_schema=self.add_suggested_values_to_schema(schema, user_input), data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an authentication error."""
self._data = dict(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reauth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
reauth_entry = self._get_reauth_entry()
self._data.update(user_input)
try:
(_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)

View File

@ -0,0 +1,73 @@
"""Diagnostics for bosch alarm."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from . import BoschAlarmConfigEntry
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BoschAlarmConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"model": entry.runtime_data.model,
"serial_number": entry.runtime_data.serial_number,
"protocol_version": entry.runtime_data.protocol_version,
"firmware_version": entry.runtime_data.firmware_version,
"areas": [
{
"id": area_id,
"name": area.name,
"all_ready": area.all_ready,
"part_ready": area.part_ready,
"faults": area.faults,
"alarms": area.alarms,
"disarmed": area.is_disarmed(),
"arming": area.is_arming(),
"pending": area.is_pending(),
"part_armed": area.is_part_armed(),
"all_armed": area.is_all_armed(),
"armed": area.is_armed(),
"triggered": area.is_triggered(),
}
for area_id, area in entry.runtime_data.areas.items()
],
"points": [
{
"id": point_id,
"name": point.name,
"open": point.is_open(),
"normal": point.is_normal(),
}
for point_id, point in entry.runtime_data.points.items()
],
"doors": [
{
"id": door_id,
"name": door.name,
"open": door.is_open(),
"locked": door.is_locked(),
}
for door_id, door in entry.runtime_data.doors.items()
],
"outputs": [
{
"id": output_id,
"name": output.name,
"active": output.is_active(),
}
for output_id, output in entry.runtime_data.outputs.items()
],
"history_events": entry.runtime_data.events,
},
}

View File

@ -0,0 +1,88 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from bosch_alarm_mode2 import Panel
from homeassistant.components.sensor import Entity
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
PARALLEL_UPDATES = 0
class BoschAlarmEntity(Entity):
"""A base entity for a bosch alarm panel."""
_attr_has_entity_name = True
def __init__(self, panel: Panel, unique_id: str) -> None:
"""Set up a entity for a bosch alarm panel."""
self.panel = panel
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
observe_alarms: bool,
observe_ready: bool,
observe_status: bool,
) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._area_id = area_id
self._area_unique_id = f"{unique_id}_area_{area_id}"
self._observe_alarms = observe_alarms
self._observe_ready = observe_ready
self._observe_status = observe_status
self._area = panel.areas[area_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._area_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(DOMAIN, unique_id),
)
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.attach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.attach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.detach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.detach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.detach(self.schedule_update_ha_state)

View File

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"faulting_points": {
"default": "mdi:alert-circle-outline"
}
}
}
}

View File

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["bosch-alarm-mode2==0.4.3"] "requirements": ["bosch-alarm-mode2==0.4.6"]
} }

View File

@ -40,7 +40,7 @@ rules:
integration-owner: done integration-owner: done
log-when-unavailable: todo log-when-unavailable: todo
parallel-updates: todo parallel-updates: todo
reauthentication-flow: todo reauthentication-flow: done
test-coverage: done test-coverage: done
# Gold # Gold
@ -62,9 +62,9 @@ rules:
entity-category: todo entity-category: todo
entity-device-class: todo entity-device-class: todo
entity-disabled-by-default: todo entity-disabled-by-default: todo
entity-translations: todo entity-translations: done
exception-translations: todo exception-translations: todo
icon-translations: todo icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:
status: exempt status: exempt

View File

@ -0,0 +1,86 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.panel import Area
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
"""Describes Bosch Alarm sensor entity."""
value_fn: Callable[[Area], int]
observe_alarms: bool = False
observe_ready: bool = False
observe_status: bool = False
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
BoschAlarmSensorEntityDescription(
key="faulting_points",
translation_key="faulting_points",
value_fn=lambda area: area.faults,
observe_ready=True,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up bosch alarm sensors."""
panel = config_entry.runtime_data
unique_id = config_entry.unique_id or config_entry.entry_id
async_add_entities(
BoschAreaSensor(panel, area_id, unique_id, template)
for area_id in panel.areas
for template in SENSOR_TYPES
)
PARALLEL_UPDATES = 0
class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
"""An area sensor entity for a bosch alarm panel."""
entity_description: BoschAlarmSensorEntityDescription
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
entity_description: BoschAlarmSensorEntityDescription,
) -> None:
"""Set up an area sensor entity for a bosch alarm panel."""
super().__init__(
panel,
area_id,
unique_id,
entity_description.observe_alarms,
entity_description.observe_ready,
entity_description.observe_status,
)
self.entity_description = entity_description
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
@property
def native_value(self) -> int:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._area)

View File

@ -22,6 +22,18 @@
"installer_code": "The installer code from your panel", "installer_code": "The installer code from your panel",
"user_code": "The user code from your panel" "user_code": "The user code from your panel"
} }
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
},
"data_description": {
"password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
}
} }
}, },
"error": { "error": {
@ -30,7 +42,26 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"device_mismatch": "Please ensure you reconfigure against the same device."
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to panel."
},
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
},
"entity": {
"sensor": {
"faulting_points": {
"name": "Faulting points",
"unit_of_measurement": "points"
}
} }
} }
} }

View File

@ -13,7 +13,7 @@
}, },
"data_description": { "data_description": {
"email": "The email address associated with your Bring! account.", "email": "The email address associated with your Bring! account.",
"password": "The password to login to your Bring! account." "password": "The password to log in to your Bring! account."
} }
}, },
"reauth_confirm": { "reauth_confirm": {

View File

@ -12,6 +12,7 @@ from buienradar.constants import (
CONDITION, CONDITION,
CONTENT, CONTENT,
DATA, DATA,
FEELTEMPERATURE,
FORECAST, FORECAST,
HUMIDITY, HUMIDITY,
MESSAGE, MESSAGE,
@ -22,6 +23,7 @@ from buienradar.constants import (
TEMPERATURE, TEMPERATURE,
VISIBILITY, VISIBILITY,
WINDAZIMUTH, WINDAZIMUTH,
WINDGUST,
WINDSPEED, WINDSPEED,
) )
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
@ -200,6 +202,14 @@ class BrData:
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
@property
def feeltemperature(self):
"""Return the feeltemperature, or None."""
try:
return float(self.data.get(FEELTEMPERATURE))
except (ValueError, TypeError):
return None
@property @property
def pressure(self): def pressure(self):
"""Return the pressure, or None.""" """Return the pressure, or None."""
@ -224,6 +234,14 @@ class BrData:
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
@property
def wind_gust(self):
"""Return the windgust, or None."""
try:
return float(self.data.get(WINDGUST))
except (ValueError, TypeError):
return None
@property @property
def wind_speed(self): def wind_speed(self):
"""Return the windspeed, or None.""" """Return the windspeed, or None."""

View File

@ -9,6 +9,7 @@ from buienradar.constants import (
MAX_TEMP, MAX_TEMP,
MIN_TEMP, MIN_TEMP,
RAIN, RAIN,
RAIN_CHANCE,
WINDAZIMUTH, WINDAZIMUTH,
WINDSPEED, WINDSPEED,
) )
@ -33,6 +34,7 @@ from homeassistant.components.weather import (
ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
Forecast, Forecast,
@ -153,7 +155,9 @@ class BrWeather(WeatherEntity):
) )
self._attr_native_pressure = data.pressure self._attr_native_pressure = data.pressure
self._attr_native_temperature = data.temperature self._attr_native_temperature = data.temperature
self._attr_native_apparent_temperature = data.feeltemperature
self._attr_native_visibility = data.visibility self._attr_native_visibility = data.visibility
self._attr_native_wind_gust_speed = data.wind_gust
self._attr_native_wind_speed = data.wind_speed self._attr_native_wind_speed = data.wind_speed
self._attr_wind_bearing = data.wind_bearing self._attr_wind_bearing = data.wind_bearing
@ -188,6 +192,7 @@ class BrWeather(WeatherEntity):
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE),
ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH),
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
} }

View File

@ -74,7 +74,7 @@
}, },
"get_events": { "get_events": {
"name": "Get events", "name": "Get events",
"description": "Get events on a calendar within a time range.", "description": "Retrieves events on a calendar within a time range.",
"fields": { "fields": {
"start_date_time": { "start_date_time": {
"name": "Start time", "name": "Start time",

View File

@ -142,6 +142,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
@property @property
def media_artist(self) -> str | None: def media_artist(self) -> str | None:
"""Artist of current playing media, music track only.""" """Artist of current playing media, music track only."""
if (
not self.client.play_state.metadata.artist
and self.client.state.source == "IR"
):
# Return channel instead of artist when playing internet radio
return self.client.play_state.metadata.station
return self.client.play_state.metadata.artist return self.client.play_state.metadata.artist
@property @property
@ -169,6 +175,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
"""Last time the media position was updated.""" """Last time the media position was updated."""
return self.client.position_last_updated return self.client.position_last_updated
@property
def media_channel(self) -> str | None:
"""Channel currently playing."""
return self.client.play_state.metadata.station
@property @property
def is_volume_muted(self) -> bool | None: def is_volume_muted(self) -> bool | None:
"""Volume mute status.""" """Volume mute status."""

View File

@ -2,17 +2,10 @@
from __future__ import annotations from __future__ import annotations
from contextlib import suppress
import logging import logging
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING, Literal, cast
with suppress(Exception): from turbojpeg import TurboJPEG
# TurboJPEG imports numpy which may or may not work so
# we have to guard the import here. We still want
# to import it at top level so it gets loaded
# in the import executor and not in the event loop.
from turbojpeg import TurboJPEG
if TYPE_CHECKING: if TYPE_CHECKING:
from . import Image from . import Image

View File

@ -8,46 +8,18 @@ from typing import Final
from canary.api import Api from canary.api import Api
from requests.exceptions import ConnectTimeout, HTTPError from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT
CONF_FFMPEG_ARGUMENTS,
DEFAULT_FFMPEG_ARGUMENTS,
DEFAULT_TIMEOUT,
DOMAIN,
)
from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30)
CONFIG_SCHEMA: Final = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(
CONF_TIMEOUT, default=DEFAULT_TIMEOUT
): cv.positive_int,
}
)
},
),
extra=vol.ALLOW_EXTRA,
)
PLATFORMS: Final[list[Platform]] = [ PLATFORMS: Final[list[Platform]] = [
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,
Platform.CAMERA, Platform.CAMERA,
@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [
] ]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Canary integration."""
if hass.config_entries.async_entries(DOMAIN):
return True
ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS
if CAMERA_DOMAIN in config:
camera_config = next(
(item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN),
None,
)
if camera_config:
ffmpeg_arguments = camera_config.get(
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
)
if DOMAIN in config:
if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS:
config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
"""Set up Canary from a config entry.""" """Set up Canary from a config entry."""
if not entry.options: if not entry.options:

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