This commit is contained in:
Franck Nijhof 2025-06-11 21:28:56 +02:00 committed by GitHub
commit 7aa6c8b941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3064 changed files with 121974 additions and 29456 deletions

View File

@ -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@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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

View File

@ -37,10 +37,10 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 12 CACHE_VERSION: 2
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.5" HA_SHORT_VERSION: "2025.6"
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
@ -259,7 +259,7 @@ jobs:
with: with:
path: venv path: venv
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
@ -276,7 +276,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies - name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true' if: steps.cache-precommit.outputs.cache-hit != 'true'
@ -306,7 +306,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@ -315,7 +315,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Run ruff-format - name: Run ruff-format
run: | run: |
@ -346,7 +346,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@ -355,7 +355,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Run ruff - name: Run ruff
run: | run: |
@ -386,7 +386,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@ -395,7 +395,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Register yamllint problem matcher - name: Register yamllint problem matcher
@ -501,7 +501,7 @@ jobs:
with: with:
path: venv path: venv
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache - name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
@ -509,10 +509,10 @@ jobs:
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-uv-key.outputs.key }} steps.generate-uv-key.outputs.key }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Install additional OS dependencies - name: Install additional OS dependencies
@ -598,7 +598,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run hassfest - name: Run hassfest
run: | run: |
@ -631,7 +631,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run gen_requirements_all.py - name: Run gen_requirements_all.py
run: | run: |
@ -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.6.0 uses: actions/dependency-review-action@v4.7.1
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@ -688,7 +688,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Extract license data - name: Extract license data
run: | run: |
@ -731,7 +731,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher - name: Register pylint problem matcher
run: | run: |
@ -778,7 +778,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher - name: Register pylint problem matcher
run: | run: |
@ -830,17 +830,17 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.3
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-mypy-key.outputs.key }} steps.generate-mypy-key.outputs.key }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{ env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Register mypy problem matcher - name: Register mypy problem matcher
@ -900,7 +900,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run split_tests.py - name: Run split_tests.py
run: | run: |
@ -944,7 +944,8 @@ jobs:
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \ libturbojpeg \
libgammu-dev libgammu-dev \
libxml2-utils
- 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 ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -959,7 +960,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@ -1019,6 +1021,12 @@ jobs:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
overwrite: true overwrite: true
- name: Beautify test results
# For easier identification of parsing errors
if: needs.info.outputs.skip_coverage != 'true'
run: |
xmllint --format "junit.xml" > "junit.xml-tmp"
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
@ -1069,7 +1077,8 @@ jobs:
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \ libturbojpeg \
libmariadb-dev-compat libmariadb-dev-compat \
libxml2-utils
- 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 ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -1084,7 +1093,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@ -1152,6 +1162,12 @@ jobs:
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
path: coverage.xml path: coverage.xml
overwrite: true overwrite: true
- name: Beautify test results
# For easier identification of parsing errors
if: needs.info.outputs.skip_coverage != 'true'
run: |
xmllint --format "junit.xml" > "junit.xml-tmp"
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
@ -1200,7 +1216,8 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg libturbojpeg \
libxml2-utils
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install \ sudo apt-get -y install \
postgresql-server-dev-14 postgresql-server-dev-14
@ -1218,7 +1235,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@ -1287,6 +1305,12 @@ jobs:
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
path: coverage.xml path: coverage.xml
overwrite: true overwrite: true
- name: Beautify test results
# For easier identification of parsing errors
if: needs.info.outputs.skip_coverage != 'true'
run: |
xmllint --format "junit.xml" > "junit.xml-tmp"
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
@ -1317,7 +1341,7 @@ jobs:
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.2 uses: codecov/codecov-action@v5.4.3
with: with:
fail_ci_if_error: true fail_ci_if_error: true
flags: full-suite flags: full-suite
@ -1354,7 +1378,8 @@ jobs:
bluez \ bluez \
ffmpeg \ ffmpeg \
libturbojpeg \ libturbojpeg \
libgammu-dev libgammu-dev \
libxml2-utils
- 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 ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -1369,7 +1394,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@ -1432,6 +1458,12 @@ jobs:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
overwrite: true overwrite: true
- name: Beautify test results
# For easier identification of parsing errors
if: needs.info.outputs.skip_coverage != 'true'
run: |
xmllint --format "junit.xml" > "junit.xml-tmp"
mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v4.6.2
@ -1459,7 +1491,7 @@ jobs:
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.2 uses: codecov/codecov-action@v5.4.3
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}

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.16 uses: github/codeql-action/init@v3.28.18
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.16 uses: github/codeql-action/analyze@v3.28.18
with: with:
category: "/language:python" category: "/language:python"

View File

@ -65,6 +65,7 @@ homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.* homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.* homeassistant.components.alert.*
homeassistant.components.alexa.* homeassistant.components.alexa.*
homeassistant.components.alexa_devices.*
homeassistant.components.alpha_vantage.* homeassistant.components.alpha_vantage.*
homeassistant.components.amazon_polly.* homeassistant.components.amazon_polly.*
homeassistant.components.amberelectric.* homeassistant.components.amberelectric.*
@ -270,6 +271,7 @@ homeassistant.components.image_processing.*
homeassistant.components.image_upload.* homeassistant.components.image_upload.*
homeassistant.components.imap.* homeassistant.components.imap.*
homeassistant.components.imgw_pib.* homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.* homeassistant.components.incomfort.*
homeassistant.components.input_button.* homeassistant.components.input_button.*
homeassistant.components.input_select.* homeassistant.components.input_select.*
@ -332,6 +334,7 @@ homeassistant.components.media_player.*
homeassistant.components.media_source.* homeassistant.components.media_source.*
homeassistant.components.met_eireann.* homeassistant.components.met_eireann.*
homeassistant.components.metoffice.* homeassistant.components.metoffice.*
homeassistant.components.miele.*
homeassistant.components.mikrotik.* homeassistant.components.mikrotik.*
homeassistant.components.min_max.* homeassistant.components.min_max.*
homeassistant.components.minecraft_server.* homeassistant.components.minecraft_server.*
@ -384,6 +387,7 @@ homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.* homeassistant.components.p1_monitor.*
homeassistant.components.pandora.* homeassistant.components.pandora.*
homeassistant.components.panel_custom.* homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.* homeassistant.components.peblar.*
homeassistant.components.peco.* homeassistant.components.peco.*
homeassistant.components.pegel_online.* homeassistant.components.pegel_online.*
@ -433,7 +437,6 @@ homeassistant.components.roku.*
homeassistant.components.romy.* homeassistant.components.romy.*
homeassistant.components.rpi_power.* homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.* homeassistant.components.rss_feed_template.*
homeassistant.components.rtsp_to_webrtc.*
homeassistant.components.russound_rio.* homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.* homeassistant.components.ruuvitag_ble.*

44
CODEOWNERS generated
View File

@ -46,8 +46,8 @@ build.json @home-assistant/supervisor
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
/tests/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray
/homeassistant/components/adax/ @danielhiversen /homeassistant/components/adax/ @danielhiversen @lazytarget
/tests/components/adax/ @danielhiversen /tests/components/adax/ @danielhiversen @lazytarget
/homeassistant/components/adguard/ @frenck /homeassistant/components/adguard/ @frenck
/tests/components/adguard/ @frenck /tests/components/adguard/ @frenck
/homeassistant/components/ads/ @mrpasztoradam /homeassistant/components/ads/ @mrpasztoradam
@ -89,6 +89,8 @@ build.json @home-assistant/supervisor
/tests/components/alert/ @home-assistant/core @frenck /tests/components/alert/ @home-assistant/core @frenck
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/homeassistant/components/alexa_devices/ @chemelli74
/tests/components/alexa_devices/ @chemelli74
/homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amazon_polly/ @jschlyter
/homeassistant/components/amberelectric/ @madpilot /homeassistant/components/amberelectric/ @madpilot
/tests/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot
@ -202,8 +204,8 @@ build.json @home-assistant/supervisor
/tests/components/blebox/ @bbx-a @swistakm /tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot @mkmer /homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @Floris272 @gleeuwen /homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @Floris272 @gleeuwen /tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco /homeassistant/components/bluemaestro/ @bdraco
/tests/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core /homeassistant/components/blueprint/ @home-assistant/core
@ -303,6 +305,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/crownstone/ @Crownstone @RicArch97
/tests/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff /homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff
/homeassistant/components/daikin/ @fredrike /homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike /tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core /homeassistant/components/date/ @home-assistant/core
@ -455,8 +458,8 @@ build.json @home-assistant/supervisor
/tests/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb /homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 @baqs /homeassistant/components/ezviz/ @RenierM26
/tests/components/ezviz/ @RenierM26 @baqs /tests/components/ezviz/ @RenierM26
/homeassistant/components/faa_delays/ @ntilley905 /homeassistant/components/faa_delays/ @ntilley905
/tests/components/faa_delays/ @ntilley905 /tests/components/faa_delays/ @ntilley905
/homeassistant/components/fan/ @home-assistant/core /homeassistant/components/fan/ @home-assistant/core
@ -710,6 +713,8 @@ build.json @home-assistant/supervisor
/tests/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/immich/ @mib1185
/tests/components/immich/ @mib1185
/homeassistant/components/improv_ble/ @emontnemery /homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh /homeassistant/components/incomfort/ @jbouwh
@ -1111,8 +1116,8 @@ build.json @home-assistant/supervisor
/tests/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23
/homeassistant/components/openuv/ @bachya /homeassistant/components/openuv/ @bachya
/tests/components/openuv/ @bachya /tests/components/openuv/ @bachya
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/tests/components/openweathermap/ @fabaff @freekode @nzapponi /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/homeassistant/components/opnsense/ @mtreinish /homeassistant/components/opnsense/ @mtreinish
/tests/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish
/homeassistant/components/opower/ @tronikos /homeassistant/components/opower/ @tronikos
@ -1138,6 +1143,8 @@ build.json @home-assistant/supervisor
/tests/components/palazzetti/ @dotvav /tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend /homeassistant/components/panel_custom/ @home-assistant/frontend
/tests/components/panel_custom/ @home-assistant/frontend /tests/components/panel_custom/ @home-assistant/frontend
/homeassistant/components/paperless_ngx/ @fvgarrel
/tests/components/paperless_ngx/ @fvgarrel
/homeassistant/components/peblar/ @frenck /homeassistant/components/peblar/ @frenck
/tests/components/peblar/ @frenck /tests/components/peblar/ @frenck
/homeassistant/components/peco/ @IceBotYT /homeassistant/components/peco/ @IceBotYT
@ -1176,6 +1183,8 @@ build.json @home-assistant/supervisor
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/private_ble_device/ @Jc2k /homeassistant/components/private_ble_device/ @Jc2k
/tests/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k
/homeassistant/components/probe_plus/ @pantherale0
/tests/components/probe_plus/ @pantherale0
/homeassistant/components/profiler/ @bdraco /homeassistant/components/profiler/ @bdraco
/tests/components/profiler/ @bdraco /tests/components/profiler/ @bdraco
/homeassistant/components/progettihwsw/ @ardaseremet /homeassistant/components/progettihwsw/ @ardaseremet
@ -1222,6 +1231,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/qnap_qsw/ @Noltari /homeassistant/components/qnap_qsw/ @Noltari
/tests/components/qnap_qsw/ @Noltari /tests/components/qnap_qsw/ @Noltari
/homeassistant/components/quantum_gateway/ @cisasteelersfan /homeassistant/components/quantum_gateway/ @cisasteelersfan
/tests/components/quantum_gateway/ @cisasteelersfan
/homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qvr_pro/ @oblogic7
/homeassistant/components/qwikswitch/ @kellerza /homeassistant/components/qwikswitch/ @kellerza
/tests/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza
@ -1307,8 +1317,6 @@ build.json @home-assistant/supervisor
/tests/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast
/homeassistant/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rss_feed_template/ @home-assistant/core
/tests/components/rss_feed_template/ @home-assistant/core /tests/components/rss_feed_template/ @home-assistant/core
/homeassistant/components/rtsp_to_webrtc/ @allenporter
/tests/components/rtsp_to_webrtc/ @allenporter
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby /homeassistant/components/russound_rio/ @noahhusby
@ -1412,6 +1420,8 @@ build.json @home-assistant/supervisor
/tests/components/sma/ @kellerza @rklomp @erwindouna /tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee /homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
/tests/components/smarla/ @explicatis @rlint-explicatis
/homeassistant/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek /homeassistant/components/smartthings/ @joostlek
@ -1486,8 +1496,8 @@ build.json @home-assistant/supervisor
/tests/components/subaru/ @G-Two /tests/components/subaru/ @G-Two
/homeassistant/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/suez_water/ @ooii @jb101010-2
/tests/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @Swamp-Ig /homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @Swamp-Ig /tests/components/sun/ @home-assistant/core
/homeassistant/components/supla/ @mwegrzynek /homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen
@ -1500,8 +1510,8 @@ build.json @home-assistant/supervisor
/tests/components/switch_as_x/ @home-assistant/core /tests/components/switch_as_x/ @home-assistant/core
/homeassistant/components/switchbee/ @jafar-atili /homeassistant/components/switchbee/ @jafar-atili
/tests/components/switchbee/ @jafar-atili /tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza /homeassistant/components/switcher_kis/ @thecode @YogevBokobza
@ -1541,8 +1551,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks
@ -1796,6 +1806,8 @@ build.json @home-assistant/supervisor
/tests/components/zeversolar/ @kvanzuijlen /tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zodiac/ @JulienTant /homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core /homeassistant/components/zone/ @home-assistant/core

View File

@ -3,6 +3,7 @@
"name": "Amazon", "name": "Amazon",
"integrations": [ "integrations": [
"alexa", "alexa",
"alexa_devices",
"amazon_polly", "amazon_polly",
"aws", "aws",
"aws_s3", "aws_s3",

View File

@ -0,0 +1,6 @@
{
"domain": "shelly",
"name": "shelly",
"integrations": ["shelly"],
"iot_standards": ["zwave"]
}

View File

@ -40,9 +40,10 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN):
entry.unique_id for entry in self._async_current_entries() entry.unique_id for entry in self._async_current_entries()
} }
hubs: list[aiopulse.Hub] = []
with suppress(TimeoutError): with suppress(TimeoutError):
async with timeout(5): async with timeout(5):
hubs: list[aiopulse.Hub] = [ hubs = [
hub hub
async for hub in aiopulse.Hub.discover() async for hub in aiopulse.Hub.discover()
if hub.id not in already_configured if hub.id not in already_configured

View File

@ -1,7 +1,7 @@
{ {
"domain": "adax", "domain": "adax",
"name": "Adax", "name": "Adax",
"codeowners": ["@danielhiversen"], "codeowners": ["@danielhiversen", "@lazytarget"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/adax", "documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling", "iot_class": "local_polling",

View File

@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
from .entity import AdvantageAirEntity, AdvantageAirThingEntity from .entity import AdvantageAirEntity, AdvantageAirThingEntity
from .models import AdvantageAirData from .models import AdvantageAirData
@ -52,8 +52,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
self._id: str = light["id"] self._id: str = light["id"]
self._attr_unique_id += f"-{self._id}" self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(ADVANTAGE_AIR_DOMAIN, self._attr_unique_id)}, identifiers={(DOMAIN, self._attr_unique_id)},
via_device=(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]), via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
manufacturer="Advantage Air", manufacturer="Advantage Air",
model=light.get("moduleType"), model=light.get("moduleType"),
name=light["name"], name=light["name"],

View File

@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .const import DOMAIN
from .entity import AdvantageAirEntity from .entity import AdvantageAirEntity
from .models import AdvantageAirData from .models import AdvantageAirData
@ -32,9 +32,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
"""Initialize the Advantage Air App.""" """Initialize the Advantage Air App."""
super().__init__(instance) super().__init__(instance)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={ identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])},
(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"])
},
manufacturer="Advantage Air", manufacturer="Advantage Air",
model=self.coordinator.data["system"]["sysType"], model=self.coordinator.data["system"]["sysType"],
name=self.coordinator.data["system"]["name"], name=self.coordinator.data["system"]["name"],

View File

@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL from .const import DOMAIN, SERVER_URL
ATTRIBUTION = "ispyconnect.com" ATTRIBUTION = "ispyconnect.com"
DEFAULT_BRAND = "Agent DVR by ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
@ -46,7 +46,7 @@ async def async_setup_entry(
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, config_entry_id=config_entry.entry_id,
identifiers={(AGENT_DOMAIN, agent_client.unique)}, identifiers={(DOMAIN, agent_client.unique)},
manufacturer="iSpyConnect", manufacturer="iSpyConnect",
name=f"Agent {agent_client.name}", name=f"Agent {agent_client.name}",
model="Agent DVR", model="Agent DVR",

View File

@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AgentDVRConfigEntry from . import AgentDVRConfigEntry
from .const import DOMAIN as AGENT_DOMAIN from .const import DOMAIN
CONF_HOME_MODE_NAME = "home" CONF_HOME_MODE_NAME = "home"
CONF_AWAY_MODE_NAME = "away" CONF_AWAY_MODE_NAME = "away"
@ -47,7 +47,7 @@ class AgentBaseStation(AlarmControlPanelEntity):
self._client = client self._client = client
self._attr_unique_id = f"{client.unique}_CP" self._attr_unique_id = f"{client.unique}_CP"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(AGENT_DOMAIN, client.unique)}, identifiers={(DOMAIN, client.unique)},
name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}", name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}",
manufacturer="Agent", manufacturer="Agent",
model=CONST_ALARM_CONTROL_PANEL_NAME, model=CONST_ALARM_CONTROL_PANEL_NAME,

View File

@ -51,9 +51,16 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
self._current_version = ( try:
await self.client.get_current_measures() self._current_version = (
).firmware_version await self.client.get_current_measures()
).firmware_version
except AirGradientError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(error)},
) from error
async def _async_update_data(self) -> AirGradientData: async def _async_update_data(self) -> AirGradientData:
try: try:

View File

@ -6,6 +6,7 @@ from typing import Any, Concatenate
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
model_id=measures.model, model_id=measures.model,
serial_number=coordinator.serial_number, serial_number=coordinator.serial_number,
sw_version=measures.firmware_version, sw_version=measures.firmware_version,
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)},
) )

View File

@ -3,6 +3,19 @@
"name": "Airthings", "name": "Airthings",
"codeowners": ["@danielhiversen", "@LaStrada"], "codeowners": ["@danielhiversen", "@LaStrada"],
"config_flow": true, "config_flow": true,
"dhcp": [
{
"hostname": "airthings-view"
},
{
"hostname": "airthings-hub",
"macaddress": "D0141190*"
},
{
"hostname": "airthings-hub",
"macaddress": "70B3D52A0*"
}
],
"documentation": "https://www.home-assistant.io/integrations/airthings", "documentation": "https://www.home-assistant.io/integrations/airthings",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["airthings"], "loggers": ["airthings"],

View File

@ -14,6 +14,7 @@ from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS,
EntityCategory, EntityCategory,
@ -78,6 +79,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
translation_key="light", translation_key="light",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
"virusRisk": SensorEntityDescription( "virusRisk": SensorEntityDescription(
key="virusRisk", key="virusRisk",
translation_key="virus_risk", translation_key="virus_risk",

View File

@ -142,7 +142,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode] return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode]
@property @property
def hvac_modes(self): def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes.""" """Return the list of available operation modes."""
airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number) airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number)
modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes] modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes]
@ -226,12 +226,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
return super()._handle_coordinator_update() return super()._handle_coordinator_update()
@property @property
def min_temp(self): def min_temp(self) -> float:
"""Return Minimum Temperature for AC of this group.""" """Return Minimum Temperature for AC of this group."""
return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint
@property @property
def max_temp(self): def max_temp(self) -> float:
"""Return Max Temperature for AC of this group.""" """Return Max Temperature for AC of this group."""
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint

View File

@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Choose AlarmDecoder Protocol", "title": "Choose AlarmDecoder protocol",
"data": { "data": {
"protocol": "Protocol" "protocol": "Protocol"
} }
@ -12,8 +12,8 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"device_baudrate": "Device Baud Rate", "device_baudrate": "Device baud rate",
"device_path": "Device Path" "device_path": "Device path"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
@ -44,36 +44,36 @@
"arm_settings": { "arm_settings": {
"title": "[%key:component::alarmdecoder::options::step::init::title%]", "title": "[%key:component::alarmdecoder::options::step::init::title%]",
"data": { "data": {
"auto_bypass": "Auto Bypass on Arm", "auto_bypass": "Auto-bypass on arm",
"code_arm_required": "Code Required for Arming", "code_arm_required": "Code required for arming",
"alt_night_mode": "Alternative Night Mode" "alt_night_mode": "Alternative night mode"
} }
}, },
"zone_select": { "zone_select": {
"title": "[%key:component::alarmdecoder::options::step::init::title%]", "title": "[%key:component::alarmdecoder::options::step::init::title%]",
"description": "Enter the zone number you'd like to to add, edit, or remove.", "description": "Enter the zone number you'd like to to add, edit, or remove.",
"data": { "data": {
"zone_number": "Zone Number" "zone_number": "Zone number"
} }
}, },
"zone_details": { "zone_details": {
"title": "[%key:component::alarmdecoder::options::step::init::title%]", "title": "[%key:component::alarmdecoder::options::step::init::title%]",
"description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.", "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave 'Zone name' blank.",
"data": { "data": {
"zone_name": "Zone Name", "zone_name": "Zone name",
"zone_type": "Zone Type", "zone_type": "Zone type",
"zone_rfid": "RF Serial", "zone_rfid": "RF serial",
"zone_loop": "RF Loop", "zone_loop": "RF loop",
"zone_relayaddr": "Relay Address", "zone_relayaddr": "Relay address",
"zone_relaychan": "Relay Channel" "zone_relaychan": "Relay channel"
} }
} }
}, },
"error": { "error": {
"relay_inclusive": "Relay Address and Relay Channel are codependent and must be included together.", "relay_inclusive": "'Relay address' and 'Relay channel' are codependent and must be included together.",
"int": "The field below must be an integer.", "int": "The field below must be an integer.",
"loop_rfid": "RF Loop cannot be used without RF Serial.", "loop_rfid": "'RF loop' cannot be used without 'RF serial'.",
"loop_range": "RF Loop must be an integer between 1 and 4." "loop_range": "'RF loop' must be an integer between 1 and 4."
} }
}, },
"services": { "services": {

View File

@ -0,0 +1,32 @@
"""Alexa Devices integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.NOTIFY,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform."""
coordinator = AmazonDevicesCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,74 @@
"""Support for binary sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from aioamazondevices.api import AmazonDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription(
key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda _device: _device.online,
),
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda _device: _device.bluetooth_state,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices binary sensors based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
)
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
"""Binary sensor device."""
entity_description: AmazonBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.is_on_fn(self.device)

View File

@ -0,0 +1,63 @@
"""Config flow for Alexa Devices integration."""
from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector
from .const import CONF_LOGIN_DATA, DOMAIN
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input:
client = AmazonEchoApi(
user_input[CONF_COUNTRY],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
data = await client.login_mode_interactive(user_input[CONF_CODE])
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
else:
await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured()
user_input.pop(CONF_CODE)
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data},
)
finally:
await client.close()
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(
CONF_COUNTRY, default=self.hass.config.country
): CountrySelector(),
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
),
)

View File

@ -0,0 +1,8 @@
"""Alexa Devices constants."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
CONF_LOGIN_DATA = "login_data"

View File

@ -0,0 +1,58 @@
"""Support for Alexa Devices."""
from datetime import timedelta
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA
SCAN_INTERVAL = 30
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Base coordinator for Alexa Devices."""
config_entry: AmazonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: AmazonConfigEntry,
) -> None:
"""Initialize the scanner."""
super().__init__(
hass,
_LOGGER,
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self.api = AmazonEchoApi(
entry.data[CONF_COUNTRY],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
await self.api.login_mode_stored_data()
return await self.api.get_devices_data()
except (CannotConnect, CannotRetrieveData) as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
except CannotAuthenticate as err:
raise ConfigEntryError("Could not authenticate") from err

View File

@ -0,0 +1,66 @@
"""Diagnostics support for Alexa Devices integration."""
from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
devices: list[dict[str, dict[str, Any]]] = [
build_device_data(device) for device in coordinator.data.values()
]
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"device_info": {
"last_update success": coordinator.last_update_success,
"last_exception": repr(coordinator.last_exception),
"devices": devices,
},
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
assert device_entry.serial_number
return build_device_data(coordinator.data[device_entry.serial_number])
def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"""Build device data for diagnostics."""
return {
"account name": device.account_name,
"capabilities": device.capabilities,
"device family": device.device_family,
"device type": device.device_type,
"device cluster members": device.device_cluster_members,
"online": device.online,
"serial number": device.serial_number,
"software version": device.software_version,
"do not disturb": device.do_not_disturb,
"response style": device.response_style,
"bluetooth state": device.bluetooth_state,
}

View File

@ -0,0 +1,57 @@
"""Defines a base Alexa Devices entity."""
from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SPEAKER_GROUP_MODEL
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AmazonDevicesCoordinator
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Defines a base Alexa Devices entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_num = serial_num
model_details = coordinator.api.get_model_details(self.device) or {}
model = model_details.get("model")
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_num)},
name=self.device.account_name,
model=model,
model_id=self.device.device_type,
manufacturer=model_details.get("manufacturer", "Amazon"),
hw_version=model_details.get("hw_version"),
sw_version=(
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
),
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
)
self.entity_description = description
self._attr_unique_id = f"{serial_num}-{description.key}"
@property
def device(self) -> AmazonDevice:
"""Return the device."""
return self.coordinator.data[self._serial_num]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._serial_num in self.coordinator.data
and self.device.online
)

View File

@ -0,0 +1,12 @@
{
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth",
"state": {
"off": "mdi:bluetooth-off"
}
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"domain": "alexa_devices",
"name": "Alexa Devices",
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/alexa_devices",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.0.6"]
}

View File

@ -0,0 +1,74 @@
"""Support for notification entity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonNotifyEntityDescription(NotifyEntityDescription):
"""Alexa Devices notify entity description."""
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
subkey: str
NOTIFY: Final = (
AmazonNotifyEntityDescription(
key="speak",
translation_key="speak",
subkey="AUDIO_PLAYER",
method=lambda api, device, message: api.call_alexa_speak(device, message),
),
AmazonNotifyEntityDescription(
key="announce",
translation_key="announce",
subkey="AUDIO_PLAYER",
method=lambda api, device, message: api.call_alexa_announcement(
device, message
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices notification entity based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in NOTIFY
for serial_num in coordinator.data
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
)
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
"""Binary sensor notify platform."""
entity_description: AmazonNotifyEntityDescription
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
await self.entity_description.method(self.coordinator.api, self.device, message)

View File

@ -0,0 +1,76 @@
rules:
# Bronze
action-setup:
status: exempt
comment: no actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: no actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: entities do not explicitly subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo
comment: all tests missing
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Network information not relevant
discovery:
status: exempt
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: todo
comment: automate the cleanup process
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done

View File

@ -0,0 +1,60 @@
{
"common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.",
"data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
},
"config": {
"flow_title": "{username}",
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
"username": "[%key:component::alexa_devices::common::data_description_username%]",
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
}
},
"notify": {
"speak": {
"name": "Speak"
},
"announce": {
"name": "Announce"
}
},
"switch": {
"do_not_disturb": {
"name": "Do not disturb"
}
}
}
}

View File

@ -0,0 +1,84 @@
"""Support for switches."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
subkey: str
method: str
SWITCHES: Final = (
AmazonSwitchEntityDescription(
key="do_not_disturb",
subkey="AUDIO_PLAYER",
translation_key="do_not_disturb",
is_on_fn=lambda _device: _device.do_not_disturb,
method="set_do_not_disturb",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices switches based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in coordinator.data
if switch_desc.subkey in coordinator.data[serial_num].capabilities
)
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
"""Switch device."""
entity_description: AmazonSwitchEntityDescription
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
if TYPE_CHECKING:
assert method is not None
await method(self.device, state)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._switch_set_state(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._switch_set_state(False)
@property
def is_on(self) -> bool:
"""Return True if switch is on."""
return self.entity_description.is_on_fn(self.device)

View File

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["androidtvremote2"], "loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.1"], "requirements": ["androidtvremote2==0.2.2"],
"zeroconf": ["_androidtvremote2._tcp.local."] "zeroconf": ["_androidtvremote2._tcp.local."]
} }

View File

@ -51,6 +51,10 @@
"app_id": "Application ID", "app_id": "Application ID",
"app_icon": "Application icon", "app_icon": "Application icon",
"app_delete": "Check to delete this application" "app_delete": "Check to delete this application"
},
"data_description": {
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
} }
} }
} }

View File

@ -17,4 +17,11 @@ CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = 0 RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024 MIN_THINKING_BUDGET = 1024
THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"] THINKING_MODELS = [
"claude-3-7-sonnet-20250219",
"claude-3-7-sonnet-latest",
"claude-opus-4-20250514",
"claude-opus-4-0",
"claude-sonnet-4-20250514",
"claude-sonnet-4-0",
]

View File

@ -294,6 +294,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
elif isinstance(response, RawMessageDeltaEvent): elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None: if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage)) chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
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)
@ -326,6 +328,7 @@ class AnthropicConversationEntity(
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_name = None
_attr_supports_streaming = True
def __init__(self, entry: AnthropicConfigEntry) -> None: def __init__(self, entry: AnthropicConfigEntry) -> None:
"""Initialize the agent.""" """Initialize the agent."""

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic", "documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["anthropic==0.47.2"] "requirements": ["anthropic==0.52.0"]
} }

View File

@ -46,11 +46,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=_SCHEMA) return self.async_show_form(step_id="user", data_schema=_SCHEMA)
host, port = user_input[CONF_HOST], user_input[CONF_PORT] host, port = user_input[CONF_HOST], user_input[CONF_PORT]
# Abort if an entry with same host and port is present.
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
# Test the connection to the host and get the current status for serial number.
try: try:
async with asyncio.timeout(CONNECTION_TIMEOUT): async with asyncio.timeout(CONNECTION_TIMEOUT):
data = APCUPSdData(await aioapcaccess.request_status(host, port)) data = APCUPSdData(await aioapcaccess.request_status(host, port))
@ -67,3 +63,30 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
title = data.name or data.model or data.serial_no or "APC UPS" title = data.name or data.model or data.serial_no or "APC UPS"
return self.async_create_entry(title=title, data=user_input) return self.async_create_entry(title=title, data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing entry."""
if user_input is None:
return self.async_show_form(step_id="reconfigure", data_schema=_SCHEMA)
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
try:
async with asyncio.timeout(CONNECTION_TIMEOUT):
data = APCUPSdData(await aioapcaccess.request_status(host, port))
except (OSError, asyncio.IncompleteReadError, TimeoutError):
errors = {"base": "cannot_connect"}
return self.async_show_form(
step_id="reconfigure", data_schema=_SCHEMA, errors=errors
)
await self.async_set_unique_id(data.serial_no)
self._abort_if_unique_id_mismatch(reason="wrong_apcupsd_daemon")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=user_input,
)

View File

@ -1,7 +1,9 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"wrong_apcupsd_daemon": "The reconfigured APC UPS Daemon is not the same as the one already configured.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"

View File

@ -62,6 +62,8 @@ async def async_setup_entry(
target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT, target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT,
min_humidity=10, min_humidity=10,
max_humidity=50, max_humidity=50,
auto_status_key=Attribute.HUMIDIFICATION_AVAILABLE,
auto_status_value=1,
default_humidity=30, default_humidity=30,
set_humidity_fn=coordinator.client.set_humidification_setpoint, set_humidity_fn=coordinator.client.set_humidification_setpoint,
) )
@ -77,6 +79,8 @@ async def async_setup_entry(
action_map=DEHUMIDIFIER_ACTION_MAP, action_map=DEHUMIDIFIER_ACTION_MAP,
current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE, current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE,
target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT, target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT,
auto_status_key=None,
auto_status_value=None,
min_humidity=40, min_humidity=40,
max_humidity=90, max_humidity=90,
default_humidity=60, default_humidity=60,
@ -100,6 +104,8 @@ class AprilaireHumidifierDescription(HumidifierEntityDescription):
target_humidity_key: str target_humidity_key: str
min_humidity: int min_humidity: int
max_humidity: int max_humidity: int
auto_status_key: str | None
auto_status_value: int | None
default_humidity: int default_humidity: int
set_humidity_fn: Callable[[int], Awaitable] set_humidity_fn: Callable[[int], Awaitable]
@ -163,14 +169,31 @@ class AprilaireHumidifierEntity(BaseAprilaireEntity, HumidifierEntity):
def min_humidity(self) -> float: def min_humidity(self) -> float:
"""Return the minimum humidity.""" """Return the minimum humidity."""
if self.is_auto_humidity_mode():
return 1
return self.entity_description.min_humidity return self.entity_description.min_humidity
@property @property
def max_humidity(self) -> float: def max_humidity(self) -> float:
"""Return the maximum humidity.""" """Return the maximum humidity."""
if self.is_auto_humidity_mode():
return 7
return self.entity_description.max_humidity return self.entity_description.max_humidity
def is_auto_humidity_mode(self) -> bool:
"""Return whether the humidifier is in auto mode."""
if self.entity_description.auto_status_key is None:
return False
return (
self.coordinator.data.get(self.entity_description.auto_status_key)
== self.entity_description.auto_status_value
)
async def async_set_humidity(self, humidity: int) -> None: async def async_set_humidity(self, humidity: int) -> None:
"""Set the humidity.""" """Set the humidity."""

View File

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyaprilaire"], "loggers": ["pyaprilaire"],
"requirements": ["pyaprilaire==0.9.0"] "requirements": ["pyaprilaire==0.9.1"]
} }

View File

@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["APsystemsEZ1"], "loggers": ["APsystemsEZ1"],
"requirements": ["apsystems-ez1==2.6.0"] "requirements": ["apsystems-ez1==2.7.0"]
} }

View File

@ -1,6 +1,9 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"last_update": {
"default": "mdi:update"
},
"salt_left_side_percentage": { "salt_left_side_percentage": {
"default": "mdi:basket-fill" "default": "mdi:basket-fill"
}, },

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from aioaquacell import Softener from aioaquacell import Softener
@ -28,7 +29,7 @@ PARALLEL_UPDATES = 1
class SoftenerSensorEntityDescription(SensorEntityDescription): class SoftenerSensorEntityDescription(SensorEntityDescription):
"""Describes Softener sensor entity.""" """Describes Softener sensor entity."""
value_fn: Callable[[Softener], StateType] value_fn: Callable[[Softener], StateType | datetime]
SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
@ -77,6 +78,12 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
"low", "low",
], ],
), ),
SoftenerSensorEntityDescription(
key="last_update",
translation_key="last_update",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda softener: softener.lastUpdate,
),
) )
@ -111,6 +118,6 @@ class SoftenerSensor(AquacellEntity, SensorEntity):
self.entity_description = description self.entity_description = description
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | datetime:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.softener) return self.entity_description.value_fn(self.softener)

View File

@ -21,6 +21,9 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"last_update": {
"name": "Last update"
},
"salt_left_side_percentage": { "salt_left_side_percentage": {
"name": "Salt left side percentage" "name": "Salt left side percentage"
}, },

View File

@ -20,9 +20,6 @@ import hass_nabucasa
import voluptuous as vol import voluptuous as vol
from homeassistant.components import conversation, stt, tts, wake_word, websocket_api from homeassistant.components import conversation, stt, tts, wake_word, websocket_api
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -92,6 +89,8 @@ KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey(
"pipeline_conversation_data" "pipeline_conversation_data"
) )
# Number of response parts to handle before streaming the response
STREAM_RESPONSE_CHARS = 60
def validate_language(data: dict[str, Any]) -> Any: def validate_language(data: dict[str, Any]) -> Any:
@ -555,7 +554,7 @@ class PipelineRun:
event_callback: PipelineEventCallback event_callback: PipelineEventCallback
language: str = None # type: ignore[assignment] language: str = None # type: ignore[assignment]
runner_data: Any | None = None runner_data: Any | None = None
intent_agent: str | None = None intent_agent: conversation.AgentInfo | None = None
tts_audio_output: str | dict[str, Any] | None = None tts_audio_output: str | dict[str, Any] | None = None
wake_word_settings: WakeWordSettings | None = None wake_word_settings: WakeWordSettings | None = None
audio_settings: AudioSettings = field(default_factory=AudioSettings) audio_settings: AudioSettings = field(default_factory=AudioSettings)
@ -591,6 +590,9 @@ class PipelineRun:
_intent_agent_only = False _intent_agent_only = False
"""If request should only be handled by agent, ignoring sentence triggers and local processing.""" """If request should only be handled by agent, ignoring sentence triggers and local processing."""
_streamed_response_text = False
"""If the conversation agent streamed response text to TTS result."""
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Set language for pipeline.""" """Set language for pipeline."""
self.language = self.pipeline.language or self.hass.config.language self.language = self.pipeline.language or self.hass.config.language
@ -652,6 +654,11 @@ class PipelineRun:
"token": self.tts_stream.token, "token": self.tts_stream.token,
"url": self.tts_stream.url, "url": self.tts_stream.url,
"mime_type": self.tts_stream.content_type, "mime_type": self.tts_stream.content_type,
"stream_response": (
self.tts_stream.supports_streaming_input
and self.intent_agent
and self.intent_agent.supports_streaming
),
} }
self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) self.process_event(PipelineEvent(PipelineEventType.RUN_START, data))
@ -899,12 +906,12 @@ class PipelineRun:
) -> str: ) -> str:
"""Run speech-to-text portion of pipeline. Returns the spoken text.""" """Run speech-to-text portion of pipeline. Returns the spoken text."""
# Create a background task to prepare the conversation agent # Create a background task to prepare the conversation agent
if self.end_stage >= PipelineStage.INTENT: if self.end_stage >= PipelineStage.INTENT and self.intent_agent:
self.hass.async_create_background_task( self.hass.async_create_background_task(
conversation.async_prepare_agent( conversation.async_prepare_agent(
self.hass, self.intent_agent, self.language self.hass, self.intent_agent.id, self.language
), ),
f"prepare conversation agent {self.intent_agent}", f"prepare conversation agent {self.intent_agent.id}",
) )
if isinstance(self.stt_provider, stt.Provider): if isinstance(self.stt_provider, stt.Provider):
@ -1045,7 +1052,7 @@ class PipelineRun:
message=f"Intent recognition engine {engine} is not found", message=f"Intent recognition engine {engine} is not found",
) )
self.intent_agent = agent_info.id self.intent_agent = agent_info
async def recognize_intent( async def recognize_intent(
self, self,
@ -1078,7 +1085,7 @@ class PipelineRun:
PipelineEvent( PipelineEvent(
PipelineEventType.INTENT_START, PipelineEventType.INTENT_START,
{ {
"engine": self.intent_agent, "engine": self.intent_agent.id,
"language": input_language, "language": input_language,
"intent_input": intent_input, "intent_input": intent_input,
"conversation_id": conversation_id, "conversation_id": conversation_id,
@ -1095,11 +1102,11 @@ class PipelineRun:
conversation_id=conversation_id, conversation_id=conversation_id,
device_id=device_id, device_id=device_id,
language=input_language, language=input_language,
agent_id=self.intent_agent, agent_id=self.intent_agent.id,
extra_system_prompt=conversation_extra_system_prompt, extra_system_prompt=conversation_extra_system_prompt,
) )
agent_id = self.intent_agent agent_id = self.intent_agent.id
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
intent_response: intent.IntentResponse | None = None intent_response: intent.IntentResponse | None = None
if not processed_locally and not self._intent_agent_only: if not processed_locally and not self._intent_agent_only:
@ -1121,7 +1128,7 @@ class PipelineRun:
# If the LLM has API access, we filter out some sentences that are # If the LLM has API access, we filter out some sentences that are
# interfering with LLM operation. # interfering with LLM operation.
if ( if (
intent_agent_state := self.hass.states.get(self.intent_agent) intent_agent_state := self.hass.states.get(self.intent_agent.id)
) and intent_agent_state.attributes.get( ) and intent_agent_state.attributes.get(
ATTR_SUPPORTED_FEATURES, 0 ATTR_SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL: ) & conversation.ConversationEntityFeature.CONTROL:
@ -1143,6 +1150,13 @@ class PipelineRun:
agent_id = conversation.HOME_ASSISTANT_AGENT agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True processed_locally = True
if self.tts_stream and self.tts_stream.supports_streaming_input:
tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
else:
tts_input_stream = None
chat_log_role = None
delta_character_count = 0
@callback @callback
def chat_log_delta_listener( def chat_log_delta_listener(
chat_log: conversation.ChatLog, delta: dict chat_log: conversation.ChatLog, delta: dict
@ -1156,6 +1170,61 @@ class PipelineRun:
}, },
) )
) )
if tts_input_stream is None:
return
nonlocal chat_log_role
if role := delta.get("role"):
chat_log_role = role
# We are only interested in assistant deltas
if chat_log_role != "assistant":
return
if content := delta.get("content"):
tts_input_stream.put_nowait(content)
if self._streamed_response_text:
return
nonlocal delta_character_count
# Streamed responses are not cached. That's why we only start streaming text after
# we have received enough characters that indicates it will be a long response
# or if we have received text, and then a tool call.
# Tool call after we already received text
start_streaming = delta_character_count > 0 and delta.get("tool_calls")
# Count characters in the content and test if we exceed streaming threshold
if not start_streaming and content:
delta_character_count += len(content)
start_streaming = delta_character_count > STREAM_RESPONSE_CHARS
if not start_streaming:
return
self._streamed_response_text = True
async def tts_input_stream_generator() -> AsyncGenerator[str]:
"""Yield TTS input stream."""
while (tts_input := await tts_input_stream.get()) is not None:
yield tts_input
# Concatenate all existing queue items
parts = []
while not tts_input_stream.empty():
parts.append(tts_input_stream.get_nowait())
tts_input_stream.put_nowait(
"".join(
# At this point parts is only strings, None indicates end of queue
cast(list[str], parts)
)
)
assert self.tts_stream is not None
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
with ( with (
chat_session.async_get_chat_session( chat_session.async_get_chat_session(
@ -1199,6 +1268,8 @@ class PipelineRun:
speech = conversation_result.response.speech.get("plain", {}).get( speech = conversation_result.response.speech.get("plain", {}).get(
"speech", "" "speech", ""
) )
if tts_input_stream and self._streamed_response_text:
tts_input_stream.put_nowait(None)
except Exception as src_error: except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition") _LOGGER.exception("Unexpected error during intent recognition")
@ -1276,26 +1347,11 @@ class PipelineRun:
) )
) )
try: if not self._streamed_response_text:
# Synthesize audio and get URL self.tts_stream.async_set_message(tts_input)
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_stream.engine,
language=self.tts_stream.language,
options=self.tts_stream.options,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
self.tts_stream.async_set_message(tts_input)
tts_output = { tts_output = {
"media_id": tts_media_id, "media_id": self.tts_stream.media_source_id,
"token": self.tts_stream.token, "token": self.tts_stream.token,
"url": self.tts_stream.url, "url": self.tts_stream.url,
"mime_type": self.tts_stream.content_type, "mime_type": self.tts_stream.content_type,

View File

@ -18,7 +18,7 @@
}, },
"step": { "step": {
"validation": { "validation": {
"title": "Two factor authentication", "title": "Two-factor authentication",
"data": { "data": {
"verification_code": "Verification code" "verification_code": "Verification code"
}, },

View File

@ -4,8 +4,8 @@
"user": { "user": {
"description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel", "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel",
"data": { "data": {
"port": "RS485 or USB-RS485 Adaptor Port", "port": "RS485 or USB-RS485 adaptor port",
"address": "Inverter Address" "address": "Inverter address"
} }
} }
}, },
@ -16,7 +16,7 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." "no_serial_ports": "No com ports found. The integration needs a valid RS485 device to communicate."
} }
}, },
"entity": { "entity": {

View File

@ -5,7 +5,7 @@
"step": { "step": {
"init": { "init": {
"title": "Set up two-factor authentication using TOTP", "title": "Set up two-factor authentication using TOTP",
"description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." "description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
} }
}, },
"error": { "error": {
@ -13,7 +13,7 @@
} }
}, },
"notify": { "notify": {
"title": "Notify One-Time Password", "title": "Notify one-time password",
"step": { "step": {
"init": { "init": {
"title": "Set up one-time password delivered by notify component", "title": "Set up one-time password delivered by notify component",

View File

@ -47,7 +47,7 @@ from .const import (
CONF_VIDEO_SOURCE, CONF_VIDEO_SOURCE,
DEFAULT_STREAM_PROFILE, DEFAULT_STREAM_PROFILE,
DEFAULT_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE,
DOMAIN as AXIS_DOMAIN, DOMAIN,
) )
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api from .hub import AxisHub, get_axis_api
@ -58,7 +58,7 @@ DEFAULT_PROTOCOL = "https"
PROTOCOL_CHOICES = ["https", "http"] PROTOCOL_CHOICES = ["https", "http"]
class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Axis config flow.""" """Handle a Axis config flow."""
VERSION = 3 VERSION = 3
@ -146,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
model = self.config[CONF_MODEL] model = self.config[CONF_MODEL]
same_model = [ same_model = [
entry.data[CONF_NAME] entry.data[CONF_NAME]
for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN) for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
] ]

View File

@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from .const import DOMAIN as AXIS_DOMAIN from .const import DOMAIN
if TYPE_CHECKING: if TYPE_CHECKING:
from .hub import AxisHub from .hub import AxisHub
@ -61,7 +61,7 @@ class AxisEntity(Entity):
self.hub = hub self.hub = hub
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(AXIS_DOMAIN, hub.unique_id)}, identifiers={(DOMAIN, hub.unique_id)},
serial_number=hub.unique_id, serial_number=hub.unique_id,
) )

View File

@ -23,6 +23,7 @@ from .const import DATA_MANAGER, DOMAIN
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .http import async_register_http_views from .http import async_register_http_views
from .manager import ( from .manager import (
AddonErrorData,
BackupManager, BackupManager,
BackupManagerError, BackupManagerError,
BackupPlatformEvent, BackupPlatformEvent,
@ -48,6 +49,7 @@ from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers from .websocket import async_register_websocket_handlers
__all__ = [ __all__ = [
"AddonErrorData",
"AddonInfo", "AddonInfo",
"AgentBackup", "AgentBackup",
"BackupAgent", "BackupAgent",
@ -79,7 +81,7 @@ __all__ = [
"suggested_filename_from_name_date", "suggested_filename_from_name_date",
] ]
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.EVENT, Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

View File

@ -30,8 +30,10 @@ class BackupCoordinatorData:
"""Class to hold backup data.""" """Class to hold backup data."""
backup_manager_state: BackupManagerState backup_manager_state: BackupManagerState
last_attempted_automatic_backup: datetime | None
last_successful_automatic_backup: datetime | None last_successful_automatic_backup: datetime | None
next_scheduled_automatic_backup: datetime | None next_scheduled_automatic_backup: datetime | None
last_event: ManagerStateEvent | BackupPlatformEvent | None
class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
@ -59,19 +61,23 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
] ]
self.backup_manager = backup_manager self.backup_manager = backup_manager
self._last_event: ManagerStateEvent | BackupPlatformEvent | None = None
@callback @callback
def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None: def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
"""Handle new event.""" """Handle new event."""
LOGGER.debug("Received backup event: %s", event) LOGGER.debug("Received backup event: %s", event)
self._last_event = event
self.config_entry.async_create_task(self.hass, self.async_refresh()) self.config_entry.async_create_task(self.hass, self.async_refresh())
async def _async_update_data(self) -> BackupCoordinatorData: async def _async_update_data(self) -> BackupCoordinatorData:
"""Update backup manager data.""" """Update backup manager data."""
return BackupCoordinatorData( return BackupCoordinatorData(
self.backup_manager.state, self.backup_manager.state,
self.backup_manager.config.data.last_attempted_automatic_backup,
self.backup_manager.config.data.last_completed_automatic_backup, self.backup_manager.config.data.last_completed_automatic_backup,
self.backup_manager.config.data.schedule.next_automatic_backup, self.backup_manager.config.data.schedule.next_automatic_backup,
self._last_event,
) )
@callback @callback

View File

@ -11,7 +11,7 @@ from .const import DOMAIN
from .coordinator import BackupDataUpdateCoordinator from .coordinator import BackupDataUpdateCoordinator
class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): class BackupManagerBaseEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
"""Base entity for backup manager.""" """Base entity for backup manager."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -19,12 +19,9 @@ class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
def __init__( def __init__(
self, self,
coordinator: BackupDataUpdateCoordinator, coordinator: BackupDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None: ) -> None:
"""Initialize base entity.""" """Initialize base entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = entity_description.key
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "backup_manager")}, identifiers={(DOMAIN, "backup_manager")},
manufacturer="Home Assistant", manufacturer="Home Assistant",
@ -34,3 +31,17 @@ class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
configuration_url="homeassistant://config/backup", configuration_url="homeassistant://config/backup",
) )
class BackupManagerEntity(BackupManagerBaseEntity):
"""Entity for backup manager."""
def __init__(
self,
coordinator: BackupDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = entity_description.key

View File

@ -0,0 +1,59 @@
"""Event platform for Home Assistant Backup integration."""
from __future__ import annotations
from typing import Final
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .entity import BackupManagerBaseEntity
from .manager import CreateBackupEvent, CreateBackupState
ATTR_BACKUP_STAGE: Final[str] = "backup_stage"
ATTR_FAILED_REASON: Final[str] = "failed_reason"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BackupConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Event set up for backup config entry."""
coordinator = config_entry.runtime_data
async_add_entities([AutomaticBackupEvent(coordinator)])
class AutomaticBackupEvent(BackupManagerBaseEntity, EventEntity):
"""Representation of an automatic backup event."""
_attr_event_types = [s.value for s in CreateBackupState]
_unrecorded_attributes = frozenset({ATTR_FAILED_REASON, ATTR_BACKUP_STAGE})
coordinator: BackupDataUpdateCoordinator
def __init__(self, coordinator: BackupDataUpdateCoordinator) -> None:
"""Initialize the automatic backup event."""
super().__init__(coordinator)
self._attr_unique_id = "automatic_backup_event"
self._attr_translation_key = "automatic_backup_event"
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
not (data := self.coordinator.data)
or (event := data.last_event) is None
or not isinstance(event, CreateBackupEvent)
):
return
self._trigger_event(
event.state,
{
ATTR_BACKUP_STAGE: event.stage,
ATTR_FAILED_REASON: event.reason,
},
)
self.async_write_ha_state()

View File

@ -1,4 +1,11 @@
{ {
"entity": {
"event": {
"automatic_backup_event": {
"default": "mdi:database"
}
}
},
"services": { "services": {
"create": { "create": {
"service": "mdi:cloud-upload" "service": "mdi:cloud-upload"

View File

@ -62,6 +62,7 @@ from .const import (
LOGGER, LOGGER,
) )
from .models import ( from .models import (
AddonInfo,
AgentBackup, AgentBackup,
BackupError, BackupError,
BackupManagerError, BackupManagerError,
@ -102,15 +103,27 @@ class ManagerBackup(BaseBackup):
"""Backup class.""" """Backup class."""
agents: dict[str, AgentBackupStatus] agents: dict[str, AgentBackupStatus]
failed_addons: list[AddonInfo]
failed_agent_ids: list[str] failed_agent_ids: list[str]
failed_folders: list[Folder]
with_automatic_settings: bool | None with_automatic_settings: bool | None
@dataclass(frozen=True, kw_only=True, slots=True)
class AddonErrorData:
"""Addon error class."""
addon: AddonInfo
errors: list[tuple[str, str]]
@dataclass(frozen=True, kw_only=True, slots=True) @dataclass(frozen=True, kw_only=True, slots=True)
class WrittenBackup: class WrittenBackup:
"""Written backup class.""" """Written backup class."""
addon_errors: dict[str, AddonErrorData]
backup: AgentBackup backup: AgentBackup
folder_errors: dict[Folder, list[tuple[str, str]]]
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]] open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]]
release_stream: Callable[[], Coroutine[Any, Any, None]] release_stream: Callable[[], Coroutine[Any, Any, None]]
@ -636,9 +649,13 @@ class BackupManager:
for agent_backup in result: for agent_backup in result:
if (backup_id := agent_backup.backup_id) not in backups: if (backup_id := agent_backup.backup_id) not in backups:
if known_backup := self.known_backups.get(backup_id): if known_backup := self.known_backups.get(backup_id):
failed_addons = known_backup.failed_addons
failed_agent_ids = known_backup.failed_agent_ids failed_agent_ids = known_backup.failed_agent_ids
failed_folders = known_backup.failed_folders
else: else:
failed_addons = []
failed_agent_ids = [] failed_agent_ids = []
failed_folders = []
with_automatic_settings = self.is_our_automatic_backup( with_automatic_settings = self.is_our_automatic_backup(
agent_backup, await instance_id.async_get(self.hass) agent_backup, await instance_id.async_get(self.hass)
) )
@ -649,7 +666,9 @@ class BackupManager:
date=agent_backup.date, date=agent_backup.date,
database_included=agent_backup.database_included, database_included=agent_backup.database_included,
extra_metadata=agent_backup.extra_metadata, extra_metadata=agent_backup.extra_metadata,
failed_addons=failed_addons,
failed_agent_ids=failed_agent_ids, failed_agent_ids=failed_agent_ids,
failed_folders=failed_folders,
folders=agent_backup.folders, folders=agent_backup.folders,
homeassistant_included=agent_backup.homeassistant_included, homeassistant_included=agent_backup.homeassistant_included,
homeassistant_version=agent_backup.homeassistant_version, homeassistant_version=agent_backup.homeassistant_version,
@ -704,9 +723,13 @@ class BackupManager:
continue continue
if backup is None: if backup is None:
if known_backup := self.known_backups.get(backup_id): if known_backup := self.known_backups.get(backup_id):
failed_addons = known_backup.failed_addons
failed_agent_ids = known_backup.failed_agent_ids failed_agent_ids = known_backup.failed_agent_ids
failed_folders = known_backup.failed_folders
else: else:
failed_addons = []
failed_agent_ids = [] failed_agent_ids = []
failed_folders = []
with_automatic_settings = self.is_our_automatic_backup( with_automatic_settings = self.is_our_automatic_backup(
result, await instance_id.async_get(self.hass) result, await instance_id.async_get(self.hass)
) )
@ -717,7 +740,9 @@ class BackupManager:
date=result.date, date=result.date,
database_included=result.database_included, database_included=result.database_included,
extra_metadata=result.extra_metadata, extra_metadata=result.extra_metadata,
failed_addons=failed_addons,
failed_agent_ids=failed_agent_ids, failed_agent_ids=failed_agent_ids,
failed_folders=failed_folders,
folders=result.folders, folders=result.folders,
homeassistant_included=result.homeassistant_included, homeassistant_included=result.homeassistant_included,
homeassistant_version=result.homeassistant_version, homeassistant_version=result.homeassistant_version,
@ -960,7 +985,7 @@ class BackupManager:
password=None, password=None,
) )
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors, []) self.known_backups.add(written_backup.backup, agent_errors, {}, {}, [])
return written_backup.backup.backup_id return written_backup.backup.backup_id
async def async_create_backup( async def async_create_backup(
@ -1198,7 +1223,11 @@ class BackupManager:
finally: finally:
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add( self.known_backups.add(
written_backup.backup, agent_errors, unavailable_agents written_backup.backup,
agent_errors,
written_backup.addon_errors,
written_backup.folder_errors,
unavailable_agents,
) )
if not agent_errors: if not agent_errors:
if with_automatic_settings: if with_automatic_settings:
@ -1208,7 +1237,9 @@ class BackupManager:
backup_success = True backup_success = True
if with_automatic_settings: if with_automatic_settings:
self._update_issue_after_agent_upload(agent_errors, unavailable_agents) self._update_issue_after_agent_upload(
written_backup, agent_errors, unavailable_agents
)
# delete old backups more numerous than copies # delete old backups more numerous than copies
# try this regardless of agent errors above # try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self) await delete_backups_exceeding_configured_count(self)
@ -1354,8 +1385,10 @@ class BackupManager:
for subscription in self._backup_event_subscriptions: for subscription in self._backup_event_subscriptions:
subscription(event) subscription(event)
def _update_issue_backup_failed(self) -> None: def _create_automatic_backup_failed_issue(
"""Update issue registry when a backup fails.""" self, translation_key: str, translation_placeholders: dict[str, str] | None
) -> None:
"""Create an issue in the issue registry for automatic backup failures."""
ir.async_create_issue( ir.async_create_issue(
self.hass, self.hass,
DOMAIN, DOMAIN,
@ -1364,37 +1397,73 @@ class BackupManager:
is_persistent=True, is_persistent=True,
learn_more_url="homeassistant://config/backup", learn_more_url="homeassistant://config/backup",
severity=ir.IssueSeverity.WARNING, severity=ir.IssueSeverity.WARNING,
translation_key="automatic_backup_failed_create", translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
def _update_issue_backup_failed(self) -> None:
"""Update issue registry when a backup fails."""
self._create_automatic_backup_failed_issue(
"automatic_backup_failed_create", None
) )
def _update_issue_after_agent_upload( def _update_issue_after_agent_upload(
self, agent_errors: dict[str, Exception], unavailable_agents: list[str] self,
written_backup: WrittenBackup,
agent_errors: dict[str, Exception],
unavailable_agents: list[str],
) -> None: ) -> None:
"""Update issue registry after a backup is uploaded to agents.""" """Update issue registry after a backup is uploaded to agents."""
if not agent_errors and not unavailable_agents:
addon_errors = written_backup.addon_errors
failed_agents = unavailable_agents + [
self.backup_agents[agent_id].name for agent_id in agent_errors
]
folder_errors = written_backup.folder_errors
if not failed_agents and not addon_errors and not folder_errors:
# No issues to report, clear previous error
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
return return
ir.async_create_issue( if failed_agents and not (addon_errors or folder_errors):
self.hass, # No issues with add-ons or folders, but issues with agents
DOMAIN, self._create_automatic_backup_failed_issue(
"automatic_backup_failed", "automatic_backup_failed_upload_agents",
is_fixable=False, {"failed_agents": ", ".join(failed_agents)},
is_persistent=True, )
learn_more_url="homeassistant://config/backup", elif addon_errors and not (failed_agents or folder_errors):
severity=ir.IssueSeverity.WARNING, # No issues with agents or folders, but issues with add-ons
translation_key="automatic_backup_failed_upload_agents", self._create_automatic_backup_failed_issue(
translation_placeholders={ "automatic_backup_failed_addons",
"failed_agents": ", ".join( {
chain( "failed_addons": ", ".join(
( val.addon.name or val.addon.slug
self.backup_agents[agent_id].name for val in addon_errors.values()
for agent_id in agent_errors
),
unavailable_agents,
) )
) },
}, )
) elif folder_errors and not (failed_agents or addon_errors):
# No issues with agents or add-ons, but issues with folders
self._create_automatic_backup_failed_issue(
"automatic_backup_failed_folders",
{"failed_folders": ", ".join(folder for folder in folder_errors)},
)
else:
# Issues with agents, add-ons, and/or folders
self._create_automatic_backup_failed_issue(
"automatic_backup_failed_agents_addons_folders",
{
"failed_agents": ", ".join(failed_agents) or "-",
"failed_addons": (
", ".join(
val.addon.name or val.addon.slug
for val in addon_errors.values()
)
or "-"
),
"failed_folders": ", ".join(f for f in folder_errors) or "-",
},
)
async def async_can_decrypt_on_download( async def async_can_decrypt_on_download(
self, self,
@ -1460,7 +1529,12 @@ class KnownBackups:
self._backups = { self._backups = {
backup["backup_id"]: KnownBackup( backup["backup_id"]: KnownBackup(
backup_id=backup["backup_id"], backup_id=backup["backup_id"],
failed_addons=[
AddonInfo(name=a["name"], slug=a["slug"], version=a["version"])
for a in backup["failed_addons"]
],
failed_agent_ids=backup["failed_agent_ids"], failed_agent_ids=backup["failed_agent_ids"],
failed_folders=[Folder(f) for f in backup["failed_folders"]],
) )
for backup in stored_backups for backup in stored_backups
} }
@ -1473,12 +1547,16 @@ class KnownBackups:
self, self,
backup: AgentBackup, backup: AgentBackup,
agent_errors: dict[str, Exception], agent_errors: dict[str, Exception],
failed_addons: dict[str, AddonErrorData],
failed_folders: dict[Folder, list[tuple[str, str]]],
unavailable_agents: list[str], unavailable_agents: list[str],
) -> None: ) -> None:
"""Add a backup.""" """Add a backup."""
self._backups[backup.backup_id] = KnownBackup( self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id, backup_id=backup.backup_id,
failed_addons=[val.addon for val in failed_addons.values()],
failed_agent_ids=list(chain(agent_errors, unavailable_agents)), failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
failed_folders=list(failed_folders),
) )
self._manager.store.save() self._manager.store.save()
@ -1499,21 +1577,38 @@ class KnownBackup:
"""Persistent backup data.""" """Persistent backup data."""
backup_id: str backup_id: str
failed_addons: list[AddonInfo]
failed_agent_ids: list[str] failed_agent_ids: list[str]
failed_folders: list[Folder]
def to_dict(self) -> StoredKnownBackup: def to_dict(self) -> StoredKnownBackup:
"""Convert known backup to a dict.""" """Convert known backup to a dict."""
return { return {
"backup_id": self.backup_id, "backup_id": self.backup_id,
"failed_addons": [
{"name": a.name, "slug": a.slug, "version": a.version}
for a in self.failed_addons
],
"failed_agent_ids": self.failed_agent_ids, "failed_agent_ids": self.failed_agent_ids,
"failed_folders": [f.value for f in self.failed_folders],
} }
class StoredAddonInfo(TypedDict):
"""Stored add-on info."""
name: str | None
slug: str
version: str | None
class StoredKnownBackup(TypedDict): class StoredKnownBackup(TypedDict):
"""Stored persistent backup data.""" """Stored persistent backup data."""
backup_id: str backup_id: str
failed_addons: list[StoredAddonInfo]
failed_agent_ids: list[str] failed_agent_ids: list[str]
failed_folders: list[str]
class CoreBackupReaderWriter(BackupReaderWriter): class CoreBackupReaderWriter(BackupReaderWriter):
@ -1677,7 +1772,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
raise BackupReaderWriterError(str(err)) from err raise BackupReaderWriterError(str(err)) from err
return WrittenBackup( return WrittenBackup(
backup=backup, open_stream=open_backup, release_stream=remove_backup addon_errors={},
backup=backup,
folder_errors={},
open_stream=open_backup,
release_stream=remove_backup,
) )
finally: finally:
# Inform integrations the backup is done # Inform integrations the backup is done
@ -1816,7 +1915,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
await async_add_executor_job(temp_file.unlink, True) await async_add_executor_job(temp_file.unlink, True)
return WrittenBackup( return WrittenBackup(
backup=backup, open_stream=open_backup, release_stream=remove_backup addon_errors={},
backup=backup,
folder_errors={},
open_stream=open_backup,
release_stream=remove_backup,
) )
async def async_restore_backup( async def async_restore_backup(

View File

@ -13,9 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
class AddonInfo: class AddonInfo:
"""Addon information.""" """Addon information."""
name: str name: str | None
slug: str slug: str
version: str version: str | None
class Folder(StrEnum): class Folder(StrEnum):

View File

@ -46,6 +46,12 @@ BACKUP_MANAGER_DESCRIPTIONS = (
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.last_successful_automatic_backup, value_fn=lambda data: data.last_successful_automatic_backup,
), ),
BackupSensorEntityDescription(
key="last_attempted_automatic_backup",
translation_key="last_attempted_automatic_backup",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.last_attempted_automatic_backup,
),
) )

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 = 6 STORAGE_VERSION_MINOR = 7
class StoredBackupData(TypedDict): class StoredBackupData(TypedDict):
@ -76,8 +76,16 @@ class _BackupStore(Store[StoredBackupData]):
# Version 1.6 adds agent retention settings # Version 1.6 adds agent retention settings
for agent in data["config"]["agents"]: for agent in data["config"]["agents"]:
data["config"]["agents"][agent]["retention"] = None data["config"]["agents"][agent]["retention"] = None
if old_minor_version < 7:
# Version 1.7 adds failing addons and folders
for backup in data["backups"]:
backup["failed_addons"] = []
backup["failed_folders"] = []
# Note: We allow reading data with major version 2. # Note: We allow reading data with major version 2 in which the unused key
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
# planned to happen after a 6 month quiet period with no minor version
# changes.
# Reject if major version is higher than 2. # Reject if major version is higher than 2.
if old_major_version > 2: if old_major_version > 2:
raise NotImplementedError raise NotImplementedError

View File

@ -11,6 +11,18 @@
"automatic_backup_failed_upload_agents": { "automatic_backup_failed_upload_agents": {
"title": "Automatic backup could not be uploaded to the configured locations", "title": "Automatic backup could not be uploaded to the configured locations",
"description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." "description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_addons": {
"title": "Not all add-ons could be included in automatic backup",
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_agents_addons_folders": {
"title": "Automatic backup was created with errors",
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
},
"automatic_backup_failed_folders": {
"title": "Not all folders could be included in automatic backup",
"description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
} }
}, },
"services": { "services": {
@ -24,6 +36,22 @@
} }
}, },
"entity": { "entity": {
"event": {
"automatic_backup_event": {
"name": "Automatic backup",
"state_attributes": {
"event_type": {
"state": {
"completed": "Completed successfully",
"failed": "Failed",
"in_progress": "In progress"
}
},
"backup_stage": { "name": "Backup stage" },
"failed_reason": { "name": "Failure reason" }
}
}
},
"sensor": { "sensor": {
"backup_manager_state": { "backup_manager_state": {
"name": "Backup Manager state", "name": "Backup Manager state",
@ -37,6 +65,9 @@
"next_scheduled_automatic_backup": { "next_scheduled_automatic_backup": {
"name": "Next scheduled automatic backup" "name": "Next scheduled automatic backup"
}, },
"last_attempted_automatic_backup": {
"name": "Last attempted automatic backup"
},
"last_successful_automatic_backup": { "last_successful_automatic_backup": {
"name": "Last successful automatic backup" "name": "Last successful automatic backup"
} }

View File

@ -332,6 +332,9 @@ def decrypt_backup(
except (DecryptError, SecureTarError, tarfile.TarError) as err: except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err) LOGGER.warning("Error decrypting backup: %s", err)
error = err error = err
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
else: else:
# Pad the output stream to the requested minimum size # Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0) padding = max(minimum_size - output_stream.tell(), 0)
@ -417,6 +420,9 @@ def encrypt_backup(
except (EncryptError, SecureTarError, tarfile.TarError) as err: except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err) LOGGER.warning("Error encrypting backup: %s", err)
error = err error = err
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
else: else:
# Pad the output stream to the requested minimum size # Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0) padding = max(minimum_size - output_stream.tell(), 0)

View File

@ -21,7 +21,6 @@ from .entity import BleBoxEntity
SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL = timedelta(seconds=5)
BLEBOX_TO_HVACMODE = { BLEBOX_TO_HVACMODE = {
None: None,
0: HVACMode.OFF, 0: HVACMode.OFF,
1: HVACMode.HEAT, 1: HVACMode.HEAT,
2: HVACMode.COOL, 2: HVACMode.COOL,
@ -59,12 +58,14 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
@property @property
def hvac_modes(self): def hvac_modes(self) -> list[HVACMode]:
"""Return list of supported HVAC modes.""" """Return list of supported HVAC modes."""
if self._feature.mode is None:
return [HVACMode.OFF]
return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]] return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]]
@property @property
def hvac_mode(self): def hvac_mode(self) -> HVACMode | None:
"""Return the desired HVAC mode.""" """Return the desired HVAC mode."""
if self._feature.is_on is None: if self._feature.is_on is None:
return None return None
@ -75,7 +76,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF
@property @property
def hvac_action(self): def hvac_action(self) -> HVACAction | None:
"""Return the actual current HVAC action.""" """Return the actual current HVAC action."""
if self._feature.hvac_action is not None: if self._feature.hvac_action is not None:
if not self._feature.is_on: if not self._feature.is_on:
@ -88,22 +89,22 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
return HVACAction.HEATING if self._feature.is_heating else HVACAction.IDLE return HVACAction.HEATING if self._feature.is_heating else HVACAction.IDLE
@property @property
def max_temp(self): def max_temp(self) -> float:
"""Return the maximum temperature supported.""" """Return the maximum temperature supported."""
return self._feature.max_temp return self._feature.max_temp
@property @property
def min_temp(self): def min_temp(self) -> float:
"""Return the maximum temperature supported.""" """Return the maximum temperature supported."""
return self._feature.min_temp return self._feature.min_temp
@property @property
def current_temperature(self): def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
return self._feature.current return self._feature.current
@property @property
def target_temperature(self): def target_temperature(self) -> float | None:
"""Return the desired thermostat temperature.""" """Return the desired thermostat temperature."""
return self._feature.desired return self._feature.desired

View File

@ -84,7 +84,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp) return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp)
@property @property
def color_mode(self): def color_mode(self) -> ColorMode:
"""Return the color mode. """Return the color mode.
Set values to _attr_ibutes if needed. Set values to _attr_ibutes if needed.
@ -92,7 +92,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) return COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF)
@property @property
def supported_color_modes(self): def supported_color_modes(self) -> set[ColorMode]:
"""Return supported color modes.""" """Return supported color modes."""
return {self.color_mode} return {self.color_mode}
@ -107,7 +107,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return self._feature.effect return self._feature.effect
@property @property
def rgb_color(self): def rgb_color(self) -> tuple[int, int, int] | None:
"""Return value for rgb.""" """Return value for rgb."""
if (rgb_hex := self._feature.rgb_hex) is None: if (rgb_hex := self._feature.rgb_hex) is None:
return None return None
@ -118,14 +118,14 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
) )
@property @property
def rgbw_color(self): def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the hue and saturation.""" """Return the hue and saturation."""
if (rgbw_hex := self._feature.rgbw_hex) is None: if (rgbw_hex := self._feature.rgbw_hex) is None:
return None return None
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4])
@property @property
def rgbww_color(self): def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
"""Return value for rgbww.""" """Return value for rgbww."""
if (rgbww_hex := self._feature.rgbww_hex) is None: if (rgbww_hex := self._feature.rgbww_hex) is None:
return None return None

View File

@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Sign-in with Blink account", "title": "Sign in with Blink account",
"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%]"
@ -30,7 +30,7 @@
"step": { "step": {
"simple_options": { "simple_options": {
"data": { "data": {
"scan_interval": "Scan Interval (seconds)" "scan_interval": "Scan interval (seconds)"
}, },
"title": "Blink options", "title": "Blink options",
"description": "Configure Blink integration" "description": "Configure Blink integration"
@ -93,7 +93,7 @@
}, },
"config_entry_id": { "config_entry_id": {
"name": "Integration ID", "name": "Integration ID",
"description": "The Blink Integration ID." "description": "The Blink integration ID."
} }
} }
} }

View File

@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE
type BlueCurrentConfigEntry = ConfigEntry[Connector] type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
CHARGE_POINTS = "CHARGE_POINTS" CHARGE_POINTS = "CHARGE_POINTS"
DATA = "data" DATA = "data"
DELAY = 5 DELAY = 5

View File

@ -0,0 +1,89 @@
"""Support for Blue Current buttons."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from bluecurrent_api.client import Client
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BlueCurrentConfigEntry, Connector
from .entity import ChargepointEntity
@dataclass(kw_only=True, frozen=True)
class ChargePointButtonEntityDescription(ButtonEntityDescription):
"""Describes a Blue Current button entity."""
function: Callable[[Client, str], Coroutine[Any, Any, None]]
CHARGE_POINT_BUTTONS = (
ChargePointButtonEntityDescription(
key="reset",
translation_key="reset",
function=lambda client, evse_id: client.reset(evse_id),
device_class=ButtonDeviceClass.RESTART,
),
ChargePointButtonEntityDescription(
key="reboot",
translation_key="reboot",
function=lambda client, evse_id: client.reboot(evse_id),
device_class=ButtonDeviceClass.RESTART,
),
ChargePointButtonEntityDescription(
key="stop_charge_session",
translation_key="stop_charge_session",
function=lambda client, evse_id: client.stop_session(evse_id),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BlueCurrentConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Blue Current buttons."""
connector: Connector = entry.runtime_data
async_add_entities(
ChargePointButton(
connector,
button,
evse_id,
)
for evse_id in connector.charge_points
for button in CHARGE_POINT_BUTTONS
)
class ChargePointButton(ChargepointEntity, ButtonEntity):
"""Define a charge point button."""
has_value = True
entity_description: ChargePointButtonEntityDescription
def __init__(
self,
connector: Connector,
description: ChargePointButtonEntityDescription,
evse_id: str,
) -> None:
"""Initialize the button."""
super().__init__(connector, evse_id)
self.entity_description = description
self._attr_unique_id = f"{description.key}_{evse_id}"
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.function(self.connector.client, self.evse_id)

View File

@ -1,7 +1,5 @@
"""Entity representing a Blue Current charge point.""" """Entity representing a Blue Current charge point."""
from abc import abstractmethod
from homeassistant.const import ATTR_NAME from homeassistant.const import ATTR_NAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -17,12 +15,12 @@ class BlueCurrentEntity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
has_value = False
def __init__(self, connector: Connector, signal: str) -> None: def __init__(self, connector: Connector, signal: str) -> None:
"""Initialize the entity.""" """Initialize the entity."""
self.connector = connector self.connector = connector
self.signal = signal self.signal = signal
self.has_value = False
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
@ -43,7 +41,6 @@ class BlueCurrentEntity(Entity):
return self.connector.connected and self.has_value return self.connector.connected and self.has_value
@callback @callback
@abstractmethod
def update_from_latest_data(self) -> None: def update_from_latest_data(self) -> None:
"""Update the entity from the latest data.""" """Update the entity from the latest data."""

View File

@ -19,6 +19,17 @@
"current_left": { "current_left": {
"default": "mdi:gauge" "default": "mdi:gauge"
} }
},
"button": {
"reset": {
"default": "mdi:restart"
},
"reboot": {
"default": "mdi:restart-alert"
},
"stop_charge_session": {
"default": "mdi:stop"
}
} }
} }
} }

View File

@ -1,7 +1,7 @@
{ {
"domain": "blue_current", "domain": "blue_current",
"name": "Blue Current", "name": "Blue Current",
"codeowners": ["@Floris272", "@gleeuwen"], "codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blue_current", "documentation": "https://www.home-assistant.io/integrations/blue_current",
"iot_class": "cloud_push", "iot_class": "cloud_push",

View File

@ -113,6 +113,17 @@
"grid_max_current": { "grid_max_current": {
"name": "Max grid current" "name": "Max grid current"
} }
},
"button": {
"stop_charge_session": {
"name": "Stop charge session"
},
"reboot": {
"name": "Reboot"
},
"reset": {
"name": "Reset"
}
} }
} }
} }

View File

@ -21,6 +21,7 @@ from .coordinator import (
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [ PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER, Platform.MEDIA_PLAYER,
] ]

View File

@ -0,0 +1,128 @@
"""Button entities for Bluesound."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pyblu import Player
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BluesoundCoordinator
from .media_player import DEFAULT_PORT
from .utils import format_unique_id
if TYPE_CHECKING:
from . import BluesoundConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BluesoundConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Bluesound entry."""
async_add_entities(
BluesoundButton(
config_entry.runtime_data.coordinator,
config_entry.runtime_data.player,
config_entry.data[CONF_PORT],
description,
)
for description in BUTTON_DESCRIPTIONS
)
@dataclass(kw_only=True, frozen=True)
class BluesoundButtonEntityDescription(ButtonEntityDescription):
"""Description for Bluesound button entities."""
press_fn: Callable[[Player], Awaitable[None]]
async def clear_sleep_timer(player: Player) -> None:
"""Clear the sleep timer."""
sleep = -1
while sleep != 0:
sleep = await player.sleep_timer()
async def set_sleep_timer(player: Player) -> None:
"""Set the sleep timer."""
await player.sleep_timer()
BUTTON_DESCRIPTIONS = [
BluesoundButtonEntityDescription(
key="set_sleep_timer",
translation_key="set_sleep_timer",
entity_registry_enabled_default=False,
press_fn=set_sleep_timer,
),
BluesoundButtonEntityDescription(
key="clear_sleep_timer",
translation_key="clear_sleep_timer",
entity_registry_enabled_default=False,
press_fn=clear_sleep_timer,
),
]
class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
"""Base class for Bluesound buttons."""
_attr_has_entity_name = True
entity_description: BluesoundButtonEntityDescription
def __init__(
self,
coordinator: BluesoundCoordinator,
player: Player,
port: int,
description: BluesoundButtonEntityDescription,
) -> None:
"""Initialize the Bluesound button."""
super().__init__(coordinator)
sync_status = coordinator.data.sync_status
self.entity_description = description
self._player = player
self._attr_unique_id = (
f"{description.key}-{format_unique_id(sync_status.mac, port)}"
)
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
model_id=sync_status.model,
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._player)

View File

@ -22,7 +22,11 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC, CONNECTION_NETWORK_MAC,
DeviceInfo, DeviceInfo,
@ -34,7 +38,7 @@ from homeassistant.helpers.dispatcher import (
) )
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util, slugify
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .coordinator import BluesoundCoordinator from .coordinator import BluesoundCoordinator
@ -488,10 +492,36 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
async def async_increase_timer(self) -> int: async def async_increase_timer(self) -> int:
"""Increase sleep time on player.""" """Increase sleep time on player."""
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_SET_TIMER}",
is_fixable=False,
breaks_in_ha_version="2025.12.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_set_sleep_timer",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
return await self._player.sleep_timer() return await self._player.sleep_timer()
async def async_clear_timer(self) -> None: async def async_clear_timer(self) -> None:
"""Clear sleep timer on player.""" """Clear sleep timer on player."""
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_CLEAR_TIMER}",
is_fixable=False,
breaks_in_ha_version="2025.12.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_clear_sleep_timer",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
sleep = 1 sleep = 1
while sleep > 0: while sleep > 0:
sleep = await self._player.sleep_timer() sleep = await self._player.sleep_timer()

View File

@ -26,6 +26,16 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
} }
}, },
"issues": {
"deprecated_service_set_sleep_timer": {
"title": "Detected use of deprecated action bluesound.set_sleep_timer",
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts."
},
"deprecated_service_clear_sleep_timer": {
"title": "Detected use of deprecated action bluesound.clear_sleep_timer",
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts."
}
},
"services": { "services": {
"join": { "join": {
"name": "Join", "name": "Join",
@ -71,5 +81,15 @@
} }
} }
} }
},
"entity": {
"button": {
"set_sleep_timer": {
"name": "Set sleep timer"
},
"clear_sleep_timer": {
"name": "Clear sleep timer"
}
}
} }
} }

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.5.1", "bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1", "bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0", "dbus-fast==2.43.0",
"habluetooth==3.48.2" "habluetooth==3.49.0"
] ]
} }

View File

@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
from .entity import BMWBaseEntity from .entity import BMWBaseEntity
if TYPE_CHECKING: if TYPE_CHECKING:
@ -111,7 +111,7 @@ class BMWButton(BMWBaseEntity, ButtonEntity):
await self.entity_description.remote_function(self.vehicle) await self.entity_description.remote_function(self.vehicle)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -22,13 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import get_default_context from homeassistant.util.ssl import get_default_context
from .const import ( from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
CONF_GCID,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
DOMAIN as BMW_DOMAIN,
SCAN_INTERVALS,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -63,7 +57,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}", name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
update_interval=timedelta( update_interval=timedelta(
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
), ),
@ -81,26 +75,26 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
except MyBMWCaptchaMissingError as err: except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow # If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="missing_captcha", translation_key="missing_captcha",
) from err ) from err
except MyBMWAuthError as err: except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues # Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success: if self.last_update_success:
raise UpdateFailed( raise UpdateFailed(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="update_failed", translation_key="update_failed",
translation_placeholders={"exception": str(err)}, translation_placeholders={"exception": str(err)},
) from err ) from err
# Clear refresh token and trigger reauth if previous update failed as well # Clear refresh token and trigger reauth if previous update failed as well
self._update_config_entry_refresh_token(None) self._update_config_entry_refresh_token(None)
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_auth", translation_key="invalid_auth",
) from err ) from err
except (MyBMWAPIError, RequestError) as err: except (MyBMWAPIError, RequestError) as err:
raise UpdateFailed( raise UpdateFailed(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="update_failed", translation_key="update_failed",
translation_placeholders={"exception": str(err)}, translation_placeholders={"exception": str(err)},
) from err ) from err

View File

@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity from .entity import BMWBaseEntity
@ -71,7 +71,7 @@ class BMWLock(BMWBaseEntity, LockEntity):
self._attr_is_locked = None self._attr_is_locked = None
self.async_write_ha_state() self.async_write_ha_state()
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex
@ -95,7 +95,7 @@ class BMWLock(BMWBaseEntity, LockEntity):
self._attr_is_locked = None self._attr_is_locked = None
self.async_write_ha_state() self.async_write_ha_state()
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -92,7 +92,7 @@ class BMWNotificationService(BaseNotificationService):
except (vol.Invalid, TypeError, ValueError) as ex: except (vol.Invalid, TypeError, ValueError) as ex:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_poi", translation_key="invalid_poi",
translation_placeholders={ translation_placeholders={
"poi_exception": str(ex), "poi_exception": str(ex),
@ -107,7 +107,7 @@ class BMWNotificationService(BaseNotificationService):
await vehicle.remote_services.trigger_send_poi(poi) await vehicle.remote_services.trigger_send_poi(poi)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity from .entity import BMWBaseEntity
@ -110,7 +110,7 @@ class BMWNumber(BMWBaseEntity, NumberEntity):
await self.entity_description.remote_service(self.vehicle, value) await self.entity_description.remote_service(self.vehicle, value)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity from .entity import BMWBaseEntity
@ -124,7 +124,7 @@ class BMWSelect(BMWBaseEntity, SelectEntity):
await self.entity_description.remote_service(self.vehicle, option) await self.entity_description.remote_service(self.vehicle, option)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -69,7 +69,7 @@
"name": "Door lock state" "name": "Door lock state"
}, },
"condition_based_services": { "condition_based_services": {
"name": "Condition based services" "name": "Condition-based services"
}, },
"check_control_messages": { "check_control_messages": {
"name": "Check control messages" "name": "Check control messages"
@ -81,7 +81,7 @@
"name": "Connection status" "name": "Connection status"
}, },
"is_pre_entry_climatization_enabled": { "is_pre_entry_climatization_enabled": {
"name": "Pre entry climatization" "name": "Pre-entry climatization"
} }
}, },
"button": { "button": {

View File

@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from . import DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity from .entity import BMWBaseEntity
@ -112,7 +112,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
await self.entity_description.remote_service_on(self.vehicle) await self.entity_description.remote_service_on(self.vehicle)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex
@ -124,7 +124,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
await self.entity_description.remote_service_off(self.vehicle) await self.entity_description.remote_service_off(self.vehicle)
except MyBMWAPIError as ex: except MyBMWAPIError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=BMW_DOMAIN, translation_domain=DOMAIN,
translation_key="remote_service_error", translation_key="remote_service_error",
translation_placeholders={"exception": str(ex)}, translation_placeholders={"exception": str(ex)},
) from ex ) from ex

View File

@ -5,7 +5,7 @@ import logging
from typing import Any from typing import Any
from aiohttp import ClientError, ClientResponseError, ClientTimeout from aiohttp import ClientError, ClientResponseError, ClientTimeout
from bond_async import Bond, BPUPSubscriptions, start_bpup from bond_async import Bond, BPUPSubscriptions, RequestorUUID, start_bpup
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -49,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool
token=token, token=token,
timeout=ClientTimeout(total=_API_TIMEOUT), timeout=ClientTimeout(total=_API_TIMEOUT),
session=async_get_clientsession(hass), session=async_get_clientsession(hass),
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
) )
hub = BondHub(bond, host) hub = BondHub(bond, host)
try: try:

View File

@ -8,7 +8,7 @@ import logging
from typing import Any from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError from aiohttp import ClientConnectionError, ClientResponseError
from bond_async import Bond from bond_async import Bond, RequestorUUID
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
@ -34,7 +34,12 @@ TOKEN_SCHEMA = vol.Schema({})
async def async_get_token(hass: HomeAssistant, host: str) -> str | None: async def async_get_token(hass: HomeAssistant, host: str) -> str | None:
"""Try to fetch the token from the bond device.""" """Try to fetch the token from the bond device."""
bond = Bond(host, "", session=async_get_clientsession(hass)) bond = Bond(
host,
"",
session=async_get_clientsession(hass),
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
)
response: dict[str, str] = {} response: dict[str, str] = {}
with contextlib.suppress(ClientConnectionError): with contextlib.suppress(ClientConnectionError):
response = await bond.token() response = await bond.token()
@ -45,7 +50,10 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
bond = Bond( bond = Bond(
data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass) data[CONF_HOST],
data[CONF_ACCESS_TOKEN],
session=async_get_clientsession(hass),
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
) )
try: try:
hub = BondHub(bond, data[CONF_HOST]) hub = BondHub(bond, data[CONF_HOST])

View File

@ -6,17 +6,31 @@ from ssl import SSLError
from bosch_alarm_mode2 import Panel from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, 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 ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.typing import ConfigType
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
from .services import setup_services
from .types import BoschAlarmConfigEntry
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type BoschAlarmConfigEntry = ConfigEntry[Panel] PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up bosch alarm services."""
setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
@ -48,8 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
mac = entry.data.get(CONF_MAC)
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
name=f"Bosch {panel.model}", name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems", manufacturer="Bosch Security Systems",

View File

@ -12,8 +12,8 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity from .entity import BoschAlarmAreaEntity
from .types import BoschAlarmConfigEntry
async def async_setup_entry( async def async_setup_entry(
@ -34,6 +34,9 @@ async def async_setup_entry(
) )
PARALLEL_UPDATES = 0
class AreaAlarmControlPanel(BoschAlarmAreaEntity, 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."""
@ -47,7 +50,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, 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."""
super().__init__(panel, area_id, unique_id, False, False, True) super().__init__(panel, area_id, unique_id, True, False, True)
self._attr_unique_id = self._area_unique_id self._attr_unique_id = self._area_unique_id
@property @property

View File

@ -0,0 +1,220 @@
"""Support for Bosch Alarm Panel binary sensors."""
from __future__ import annotations
from dataclasses import dataclass
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription):
"""Describes Bosch Alarm sensor entity."""
fault: int
FAULT_TYPES = [
BoschAlarmFaultEntityDescription(
key="panel_fault_battery_low",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.BATTERY,
fault=ALARM_PANEL_FAULTS.BATTERY_LOW,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_battery_mising",
translation_key="panel_fault_battery_mising",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.BATTERY_MISING,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_ac_fail",
translation_key="panel_fault_ac_fail",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.AC_FAIL,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_phone_line_failure",
translation_key="panel_fault_phone_line_failure",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_parameter_crc_fail_in_pif",
translation_key="panel_fault_parameter_crc_fail_in_pif",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_communication_fail_since_rps_hang_up",
translation_key="panel_fault_communication_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_sdi_fail_since_rps_hang_up",
translation_key="panel_fault_sdi_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_user_code_tamper_since_rps_hang_up",
translation_key="panel_fault_user_code_tamper_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_fail_to_call_rps_since_rps_hang_up",
translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up",
entity_registry_enabled_default=False,
fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_point_bus_fail_since_rps_hang_up",
translation_key="panel_fault_point_bus_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_log_overflow",
translation_key="panel_fault_log_overflow",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_log_threshold",
translation_key="panel_fault_log_threshold",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors for alarm points and the connection status."""
panel = config_entry.runtime_data
entities: list[BinarySensorEntity] = [
PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id)
for point_id in panel.points
]
entities.extend(
PanelFaultsSensor(
panel,
config_entry.unique_id or config_entry.entry_id,
fault_type,
)
for fault_type in FAULT_TYPES
)
entities.extend(
AreaReadyToArmSensor(
panel, area_id, config_entry.unique_id or config_entry.entry_id, "away"
)
for area_id in panel.areas
)
entities.extend(
AreaReadyToArmSensor(
panel, area_id, config_entry.unique_id or config_entry.entry_id, "home"
)
for area_id in panel.areas
)
async_add_entities(entities)
PARALLEL_UPDATES = 0
class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity):
"""A binary sensor entity for each fault type in a bosch alarm panel."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: BoschAlarmFaultEntityDescription
def __init__(
self,
panel: Panel,
unique_id: str,
entity_description: BoschAlarmFaultEntityDescription,
) -> None:
"""Set up a binary sensor entity for each fault type in a bosch alarm panel."""
super().__init__(panel, unique_id, True)
self.entity_description = entity_description
self._fault_type = entity_description.fault
self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}"
@property
def is_on(self) -> bool:
"""Return if this fault has occurred."""
return self._fault_type in self.panel.panel_faults_ids
class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity):
"""A binary sensor entity showing if a panel is ready to arm."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, panel: Panel, area_id: int, unique_id: str, arm_type: str
) -> None:
"""Set up a binary sensor entity for the arming status in a bosch alarm panel."""
super().__init__(panel, area_id, unique_id, False, False, True)
self.panel = panel
self._arm_type = arm_type
self._attr_translation_key = f"area_ready_to_arm_{arm_type}"
self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}"
@property
def is_on(self) -> bool:
"""Return if this panel is ready to arm."""
if self._arm_type == "away":
return self._area.all_ready
if self._arm_type == "home":
return self._area.all_ready or self._area.part_ready
return False
class PointSensor(BoschAlarmPointEntity, BinarySensorEntity):
"""A binary sensor entity for a point in a bosch alarm panel."""
_attr_name = None
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
"""Set up a binary sensor entity for a point in a bosch alarm panel."""
super().__init__(panel, point_id, unique_id)
self._attr_unique_id = self._point_unique_id
@property
def is_on(self) -> bool:
"""Return if this point sensor is on."""
return self._point.is_open()

View File

@ -6,25 +6,30 @@ import asyncio
from collections.abc import Mapping from collections.abc import Mapping
import logging import logging
import ssl import ssl
from typing import Any from typing import Any, Self
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 ( from homeassistant.config_entries import (
SOURCE_DHCP,
SOURCE_RECONFIGURE, SOURCE_RECONFIGURE,
SOURCE_USER, SOURCE_USER,
ConfigEntryState,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_CODE, CONF_CODE,
CONF_HOST, CONF_HOST,
CONF_MAC,
CONF_MODEL, CONF_MODEL,
CONF_PASSWORD, CONF_PASSWORD,
CONF_PORT, CONF_PORT,
) )
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
@ -88,6 +93,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Init config flow.""" """Init config flow."""
self._data: dict[str, Any] = {} self._data: dict[str, Any] = {}
self.mac: str | None = None
self.host: str | None = None
def is_matching(self, other_flow: Self) -> bool:
"""Return True if other_flow is matching this flow."""
return self.mac == other_flow.mac or self.host == other_flow.host
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -96,9 +107,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self.host = user_input[CONF_HOST]
if self.source == SOURCE_USER:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try: try:
# Use load_selector = 0 to fetch the panel model without authentication. # Use load_selector = 0 to fetch the panel model without authentication.
(model, serial) = await try_connect(user_input, 0) (model, _) = await try_connect(user_input, 0)
except ( except (
OSError, OSError,
ConnectionRefusedError, ConnectionRefusedError,
@ -129,6 +143,70 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self.mac = format_mac(discovery_info.macaddress)
self.host = discovery_info.ip
if self.hass.config_entries.flow.async_has_matching_flow(self):
return self.async_abort(reason="already_in_progress")
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.data.get(CONF_MAC) == self.mac:
result = self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_HOST: discovery_info.ip,
},
)
if result:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
if entry.data[CONF_HOST] == discovery_info.ip:
if (
not entry.data.get(CONF_MAC)
and entry.state is ConfigEntryState.LOADED
):
result = self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_MAC: self.mac,
},
)
if result:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
try:
# Use load_selector = 0 to fetch the panel model without authentication.
(model, _) = await try_connect(
{CONF_HOST: discovery_info.ip, CONF_PORT: 7700}, 0
)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
asyncio.exceptions.TimeoutError,
):
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
self.context["title_placeholders"] = {
"model": model,
"host": discovery_info.ip,
}
self._data = {
CONF_HOST: discovery_info.ip,
CONF_MAC: self.mac,
CONF_MODEL: model,
CONF_PORT: 7700,
}
return await self.async_step_auth()
async def async_step_reconfigure( async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -172,7 +250,7 @@ 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))
if self.source == SOURCE_USER: if self.source in (SOURCE_USER, SOURCE_DHCP):
if serial_number: if serial_number:
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
else: else:
@ -184,6 +262,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
) )
if serial_number: if serial_number:
self._abort_if_unique_id_mismatch(reason="device_mismatch") self._abort_if_unique_id_mismatch(reason="device_mismatch")
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), self._get_reconfigure_entry(),
data=self._data, data=self._data,

View File

@ -1,6 +1,9 @@
"""Constants for the Bosch Alarm integration.""" """Constants for the Bosch Alarm integration."""
DOMAIN = "bosch_alarm" DOMAIN = "bosch_alarm"
HISTORY_ATTR = "history" ATTR_HISTORY = "history"
CONF_INSTALLER_CODE = "installer_code" CONF_INSTALLER_CODE = "installer_code"
CONF_USER_CODE = "user_code" CONF_USER_CODE = "user_code"
ATTR_DATETIME = "datetime"
SERVICE_SET_DATE_TIME = "set_date_time"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"

View File

@ -6,8 +6,8 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import BoschAlarmConfigEntry
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
from .types import BoschAlarmConfigEntry
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]

View File

@ -17,9 +17,13 @@ class BoschAlarmEntity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, panel: Panel, unique_id: str) -> None: def __init__(
self, panel: Panel, unique_id: str, observe_faults: bool = False
) -> None:
"""Set up a entity for a bosch alarm panel.""" """Set up a entity for a bosch alarm panel."""
self.panel = panel self.panel = panel
self._observe_faults = observe_faults
self._attr_should_poll = False
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model}", name=f"Bosch {panel.model}",
@ -34,10 +38,14 @@ class BoschAlarmEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Observe state changes.""" """Observe state changes."""
self.panel.connection_status_observer.attach(self.schedule_update_ha_state) self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
if self._observe_faults:
self.panel.faults_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes.""" """Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state) self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
if self._observe_faults:
self.panel.faults_observer.attach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity): class BoschAlarmAreaEntity(BoschAlarmEntity):
@ -86,3 +94,84 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
self._area.ready_observer.detach(self.schedule_update_ha_state) self._area.ready_observer.detach(self.schedule_update_ha_state)
if self._observe_status: if self._observe_status:
self._area.status_observer.detach(self.schedule_update_ha_state) self._area.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmPointEntity(BoschAlarmEntity):
"""A base entity for point related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._point_id = point_id
self._point_unique_id = f"{unique_id}_point_{point_id}"
self._point = panel.points[point_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._point_unique_id)},
name=self._point.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()
self._point.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()
self._point.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmDoorEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._door_id = door_id
self._door = panel.doors[door_id]
self._door_unique_id = f"{unique_id}_door_{door_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._door_unique_id)},
name=self._door.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()
self._door.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()
self._door.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmOutputEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
"""Set up a output related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._output_id = output_id
self._output = panel.outputs[output_id]
self._output_unique_id = f"{unique_id}_output_{output_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._output_unique_id)},
name=self._output.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()
self._output.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()
self._output.status_observer.detach(self.schedule_update_ha_state)

View File

@ -1,8 +1,80 @@
{ {
"services": {
"set_date_time": {
"service": "mdi:clock-edit"
}
},
"entity": { "entity": {
"sensor": { "sensor": {
"alarms_gas": {
"default": "mdi:alert-circle"
},
"alarms_fire": {
"default": "mdi:alert-circle"
},
"alarms_burglary": {
"default": "mdi:alert-circle"
},
"faulting_points": { "faulting_points": {
"default": "mdi:alert-circle-outline" "default": "mdi:alert-circle"
}
},
"switch": {
"locked": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-open"
}
},
"secured": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-open"
}
},
"cycling": {
"default": "mdi:lock",
"state": {
"on": "mdi:lock-open"
}
}
},
"binary_sensor": {
"panel_fault_parameter_crc_fail_in_pif": {
"default": "mdi:alert-circle"
},
"panel_fault_phone_line_failure": {
"default": "mdi:alert-circle"
},
"panel_fault_sdi_fail_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_user_code_tamper_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_point_bus_fail_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_log_overflow": {
"default": "mdi:alert-circle"
},
"panel_fault_log_threshold": {
"default": "mdi:alert-circle"
},
"area_ready_to_arm_away": {
"default": "mdi:shield",
"state": {
"on": "mdi:shield-lock"
}
},
"area_ready_to_arm_home": {
"default": "mdi:shield",
"state": {
"on": "mdi:shield-home"
}
} }
} }
} }

View File

@ -3,6 +3,11 @@
"name": "Bosch Alarm", "name": "Bosch Alarm",
"codeowners": ["@mag1024", "@sanjay900"], "codeowners": ["@mag1024", "@sanjay900"],
"config_flow": true, "config_flow": true,
"dhcp": [
{
"macaddress": "000463*"
}
],
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm", "documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",

View File

@ -13,10 +13,7 @@ rules:
config-flow-test-coverage: done config-flow-test-coverage: done
config-flow: done config-flow: done
dependency-transparency: done dependency-transparency: done
docs-actions: docs-actions: done
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done docs-high-level-description: done
docs-installation-instructions: done docs-installation-instructions: done
docs-removal-instructions: done docs-removal-instructions: done
@ -29,25 +26,22 @@ rules:
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: action-exceptions: done
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: todo docs-configuration-parameters: todo
docs-installation-parameters: todo docs-installation-parameters: todo
entity-unavailable: todo entity-unavailable: todo
integration-owner: done integration-owner: done
log-when-unavailable: todo log-when-unavailable: todo
parallel-updates: todo parallel-updates: done
reauthentication-flow: done reauthentication-flow: done
test-coverage: done test-coverage: done
# Gold # Gold
devices: done devices: done
diagnostics: todo diagnostics: todo
discovery-update-info: todo discovery-update-info: done
discovery: todo discovery: done
docs-data-update: todo docs-data-update: todo
docs-examples: todo docs-examples: todo
docs-known-limitations: todo docs-known-limitations: todo

View File

@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from bosch_alarm_mode2 import Panel from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES
from bosch_alarm_mode2.panel import Area from bosch_alarm_mode2.panel import Area
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
@ -15,18 +16,53 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity from .entity import BoschAlarmAreaEntity
ALARM_TYPES = {
"burglary": {
ALARM_MEMORY_PRIORITIES.BURGLARY_SUPERVISORY: "supervisory",
ALARM_MEMORY_PRIORITIES.BURGLARY_TROUBLE: "trouble",
ALARM_MEMORY_PRIORITIES.BURGLARY_ALARM: "alarm",
},
"gas": {
ALARM_MEMORY_PRIORITIES.GAS_SUPERVISORY: "supervisory",
ALARM_MEMORY_PRIORITIES.GAS_TROUBLE: "trouble",
ALARM_MEMORY_PRIORITIES.GAS_ALARM: "alarm",
},
"fire": {
ALARM_MEMORY_PRIORITIES.FIRE_SUPERVISORY: "supervisory",
ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE: "trouble",
ALARM_MEMORY_PRIORITIES.FIRE_ALARM: "alarm",
},
}
@dataclass(kw_only=True, frozen=True) @dataclass(kw_only=True, frozen=True)
class BoschAlarmSensorEntityDescription(SensorEntityDescription): class BoschAlarmSensorEntityDescription(SensorEntityDescription):
"""Describes Bosch Alarm sensor entity.""" """Describes Bosch Alarm sensor entity."""
value_fn: Callable[[Area], int] value_fn: Callable[[Area], str | int]
observe_alarms: bool = False observe_alarms: bool = False
observe_ready: bool = False observe_ready: bool = False
observe_status: bool = False observe_status: bool = False
def priority_value_fn(priority_info: dict[int, str]) -> Callable[[Area], str]:
"""Build a value_fn for a given priority type."""
return lambda area: next(
(key for priority, key in priority_info.items() if priority in area.alarms_ids),
"no_issues",
)
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
*[
BoschAlarmSensorEntityDescription(
key=f"alarms_{key}",
translation_key=f"alarms_{key}",
value_fn=priority_value_fn(priority_type),
observe_alarms=True,
)
for key, priority_type in ALARM_TYPES.items()
],
BoschAlarmSensorEntityDescription( BoschAlarmSensorEntityDescription(
key="faulting_points", key="faulting_points",
translation_key="faulting_points", translation_key="faulting_points",
@ -81,6 +117,6 @@ class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
@property @property
def native_value(self) -> int: def native_value(self) -> str | int:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self._area) return self.entity_description.value_fn(self._area)

View File

@ -0,0 +1,77 @@
"""Services for the bosch_alarm integration."""
from __future__ import annotations
import asyncio
import datetime as dt
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
from .types import BoschAlarmConfigEntry
def validate_datetime(value: Any) -> dt.datetime:
"""Validate that a provided datetime is supported on a bosch alarm panel."""
date_val = cv.datetime(value)
if date_val.year < 2010:
raise vol.RangeInvalid("datetime must be after 2009")
if date_val.year > 2037:
raise vol.RangeInvalid("datetime must be before 2038")
return date_val
SET_DATE_TIME_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Optional(ATTR_DATETIME): validate_datetime,
}
)
async def async_set_panel_date(call: ServiceCall) -> None:
"""Set the date and time on a bosch alarm panel."""
config_entry: BoschAlarmConfigEntry | None
value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now())
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": entry_id},
)
if config_entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": config_entry.title},
)
panel = config_entry.runtime_data
try:
await panel.set_panel_date(value)
except asyncio.InvalidStateError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={"target": config_entry.title},
) from err
def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the bosch alarm integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_DATE_TIME,
async_set_panel_date,
schema=SET_DATE_TIME_SCHEMA,
)

View File

@ -0,0 +1,12 @@
set_date_time:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: bosch_alarm
datetime:
required: false
example: "2025-05-10 00:00:00"
selector:
datetime:

View File

@ -1,5 +1,6 @@
{ {
"config": { "config": {
"flow_title": "{model} ({host})",
"step": { "step": {
"user": { "user": {
"data": { "data": {
@ -42,6 +43,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"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%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
@ -49,15 +51,130 @@
} }
}, },
"exceptions": { "exceptions": {
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
},
"connection_error": {
"message": "Could not connect to \"{target}\"."
},
"unknown_error": {
"message": "An unknown error occurred while setting the date and time on \"{target}\"."
},
"cannot_connect": { "cannot_connect": {
"message": "Could not connect to panel." "message": "Could not connect to panel."
}, },
"authentication_failed": { "authentication_failed": {
"message": "Incorrect credentials for panel." "message": "Incorrect credentials for panel."
},
"incorrect_door_state": {
"message": "Door cannot be manipulated while it is momentarily unlocked."
}
},
"services": {
"set_date_time": {
"name": "Set date & time",
"description": "Sets the date and time on the alarm panel.",
"fields": {
"datetime": {
"name": "Date & time",
"description": "The date and time to set. The time zone of the Home Assistant instance is assumed. If omitted, the current date and time is used."
},
"config_entry_id": {
"name": "Config entry",
"description": "The Bosch Alarm integration ID."
}
}
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"panel_fault_battery_mising": {
"name": "Battery missing"
},
"panel_fault_ac_fail": {
"name": "AC Failure"
},
"panel_fault_parameter_crc_fail_in_pif": {
"name": "CRC failure in panel configuration"
},
"panel_fault_phone_line_failure": {
"name": "Phone line failure"
},
"panel_fault_sdi_fail_since_rps_hang_up": {
"name": "SDI failure since last RPS connection"
},
"panel_fault_user_code_tamper_since_rps_hang_up": {
"name": "User code tamper since last RPS connection"
},
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
"name": "Failure to call RPS since last RPS connection"
},
"panel_fault_point_bus_fail_since_rps_hang_up": {
"name": "Point bus failure since last RPS connection"
},
"panel_fault_log_overflow": {
"name": "Log overflow"
},
"panel_fault_log_threshold": {
"name": "Log threshold reached"
},
"area_ready_to_arm_away": {
"name": "Area ready to arm away",
"state": {
"on": "Ready",
"off": "Not ready"
}
},
"area_ready_to_arm_home": {
"name": "Area ready to arm home",
"state": {
"on": "Ready",
"off": "Not ready"
}
}
},
"switch": {
"secured": {
"name": "Secured"
},
"cycling": {
"name": "Momentarily unlocked"
},
"locked": {
"name": "Locked"
}
},
"sensor": { "sensor": {
"alarms_gas": {
"name": "Gas alarm issues",
"state": {
"supervisory": "Supervisory",
"trouble": "Trouble",
"alarm": "Alarm",
"no_issues": "No issues"
}
},
"alarms_fire": {
"name": "Fire alarm issues",
"state": {
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
}
},
"alarms_burglary": {
"name": "Burglary alarm issues",
"state": {
"supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]",
"trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]",
"alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]",
"no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]"
}
},
"faulting_points": { "faulting_points": {
"name": "Faulting points", "name": "Faulting points",
"unit_of_measurement": "points" "unit_of_measurement": "points"

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