forked from home-assistant/core
Compare commits
1 Commits
master
...
drop-ignor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d963fd8896 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
102
.github/workflows/ci.yaml
vendored
102
.github/workflows/ci.yaml
vendored
@@ -37,9 +37,9 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 2
|
||||
CACHE_VERSION: 12
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.6"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
@@ -259,7 +259,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Install pre-commit dependencies
|
||||
if: steps.cache-precommit.outputs.cache-hit != 'true'
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Run ruff-format
|
||||
run: |
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Run ruff
|
||||
run: |
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
|
||||
- name: Register yamllint problem matcher
|
||||
@@ -501,7 +501,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore uv wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -509,10 +509,10 @@ jobs:
|
||||
with:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
steps.generate-uv-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Install additional OS dependencies
|
||||
@@ -598,7 +598,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run hassfest
|
||||
run: |
|
||||
@@ -631,7 +631,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run gen_requirements_all.py
|
||||
run: |
|
||||
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.7.1
|
||||
uses: actions/dependency-review-action@v4.6.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -688,7 +688,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Extract license data
|
||||
run: |
|
||||
@@ -731,7 +731,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
@@ -741,14 +741,14 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant
|
||||
pylint homeassistant
|
||||
- name: Run pylint (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
pylint homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
|
||||
pylint-tests:
|
||||
name: Check pylint on tests
|
||||
@@ -778,7 +778,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
@@ -830,17 +830,17 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@v4.2.3
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
steps.generate-mypy-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
||||
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Register mypy problem matcher
|
||||
@@ -900,7 +900,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run split_tests.py
|
||||
run: |
|
||||
@@ -944,8 +944,7 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
libgammu-dev
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -960,8 +959,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1021,12 +1019,6 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
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
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1077,8 +1069,7 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libmariadb-dev-compat \
|
||||
libxml2-utils
|
||||
libmariadb-dev-compat
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1093,8 +1084,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1162,12 +1152,6 @@ jobs:
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
path: coverage.xml
|
||||
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
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1216,8 +1200,7 @@ jobs:
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libxml2-utils
|
||||
libturbojpeg
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
postgresql-server-dev-14
|
||||
@@ -1235,8 +1218,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1305,12 +1287,6 @@ jobs:
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
path: coverage.xml
|
||||
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
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1341,7 +1317,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1378,8 +1354,7 @@ jobs:
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg \
|
||||
libgammu-dev \
|
||||
libxml2-utils
|
||||
libgammu-dev
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -1394,8 +1369,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1458,12 +1432,6 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
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
|
||||
if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
@@ -1491,7 +1459,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v5.4.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.18
|
||||
uses: github/codeql-action/init@v3.28.16
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.18
|
||||
uses: github/codeql-action/analyze@v3.28.16
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -65,7 +65,6 @@ homeassistant.components.aladdin_connect.*
|
||||
homeassistant.components.alarm_control_panel.*
|
||||
homeassistant.components.alert.*
|
||||
homeassistant.components.alexa.*
|
||||
homeassistant.components.alexa_devices.*
|
||||
homeassistant.components.alpha_vantage.*
|
||||
homeassistant.components.amazon_polly.*
|
||||
homeassistant.components.amberelectric.*
|
||||
@@ -271,7 +270,6 @@ homeassistant.components.image_processing.*
|
||||
homeassistant.components.image_upload.*
|
||||
homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
@@ -387,7 +385,6 @@ homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.pandora.*
|
||||
homeassistant.components.panel_custom.*
|
||||
homeassistant.components.paperless_ngx.*
|
||||
homeassistant.components.peblar.*
|
||||
homeassistant.components.peco.*
|
||||
homeassistant.components.pegel_online.*
|
||||
@@ -437,6 +434,7 @@ homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.rtsp_to_webrtc.*
|
||||
homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
|
||||
52
CODEOWNERS
generated
52
CODEOWNERS
generated
@@ -46,8 +46,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/accuweather/ @bieniu
|
||||
/homeassistant/components/acmeda/ @atmurray
|
||||
/tests/components/acmeda/ @atmurray
|
||||
/homeassistant/components/adax/ @danielhiversen @lazytarget
|
||||
/tests/components/adax/ @danielhiversen @lazytarget
|
||||
/homeassistant/components/adax/ @danielhiversen
|
||||
/tests/components/adax/ @danielhiversen
|
||||
/homeassistant/components/adguard/ @frenck
|
||||
/tests/components/adguard/ @frenck
|
||||
/homeassistant/components/ads/ @mrpasztoradam
|
||||
@@ -89,8 +89,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/alert/ @home-assistant/core @frenck
|
||||
/homeassistant/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/amberelectric/ @madpilot
|
||||
/tests/components/amberelectric/ @madpilot
|
||||
@@ -173,8 +171,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @danielsjf
|
||||
/tests/components/awair/ @ahayworth @danielsjf
|
||||
/homeassistant/components/aws_s3/ @tomasbedrich
|
||||
/tests/components/aws_s3/ @tomasbedrich
|
||||
/homeassistant/components/axis/ @Kane610
|
||||
/tests/components/axis/ @Kane610
|
||||
/homeassistant/components/azure_data_explorer/ @kaareseras
|
||||
@@ -204,8 +200,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/blebox/ @bbx-a @swistakm
|
||||
/homeassistant/components/blink/ @fronzbot @mkmer
|
||||
/tests/components/blink/ @fronzbot @mkmer
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/homeassistant/components/blue_current/ @Floris272 @gleeuwen
|
||||
/tests/components/blue_current/ @Floris272 @gleeuwen
|
||||
/homeassistant/components/bluemaestro/ @bdraco
|
||||
/tests/components/bluemaestro/ @bdraco
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
@@ -305,7 +301,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/crownstone/ @Crownstone @RicArch97
|
||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/tests/components/cups/ @fabaff
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
@@ -458,8 +453,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/evil_genius_labs/ @balloob
|
||||
/homeassistant/components/evohome/ @zxdavb
|
||||
/tests/components/evohome/ @zxdavb
|
||||
/homeassistant/components/ezviz/ @RenierM26
|
||||
/tests/components/ezviz/ @RenierM26
|
||||
/homeassistant/components/ezviz/ @RenierM26 @baqs
|
||||
/tests/components/ezviz/ @RenierM26 @baqs
|
||||
/homeassistant/components/faa_delays/ @ntilley905
|
||||
/tests/components/faa_delays/ @ntilley905
|
||||
/homeassistant/components/fan/ @home-assistant/core
|
||||
@@ -713,8 +708,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/imeon_inverter/ @Imeon-Energy
|
||||
/homeassistant/components/imgw_pib/ @bieniu
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
@@ -1116,8 +1109,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/opentherm_gw/ @mvn23
|
||||
/homeassistant/components/openuv/ @bachya
|
||||
/tests/components/openuv/ @bachya
|
||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi
|
||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi
|
||||
/homeassistant/components/opnsense/ @mtreinish
|
||||
/tests/components/opnsense/ @mtreinish
|
||||
/homeassistant/components/opower/ @tronikos
|
||||
@@ -1143,8 +1136,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/palazzetti/ @dotvav
|
||||
/homeassistant/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
|
||||
/tests/components/peblar/ @frenck
|
||||
/homeassistant/components/peco/ @IceBotYT
|
||||
@@ -1183,8 +1174,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/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
|
||||
/tests/components/profiler/ @bdraco
|
||||
/homeassistant/components/progettihwsw/ @ardaseremet
|
||||
@@ -1231,7 +1220,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/qnap_qsw/ @Noltari
|
||||
/tests/components/qnap_qsw/ @Noltari
|
||||
/homeassistant/components/quantum_gateway/ @cisasteelersfan
|
||||
/tests/components/quantum_gateway/ @cisasteelersfan
|
||||
/homeassistant/components/qvr_pro/ @oblogic7
|
||||
/homeassistant/components/qwikswitch/ @kellerza
|
||||
/tests/components/qwikswitch/ @kellerza
|
||||
@@ -1317,6 +1305,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rpi_power/ @shenxn @swetoast
|
||||
/homeassistant/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
|
||||
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||
/homeassistant/components/russound_rio/ @noahhusby
|
||||
@@ -1328,6 +1318,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ruuvitag_ble/ @akx
|
||||
/homeassistant/components/rympro/ @OnFreund @elad-bar @maorcc
|
||||
/tests/components/rympro/ @OnFreund @elad-bar @maorcc
|
||||
/homeassistant/components/s3/ @tomasbedrich
|
||||
/tests/components/s3/ @tomasbedrich
|
||||
/homeassistant/components/sabnzbd/ @shaiu @jpbede
|
||||
/tests/components/sabnzbd/ @shaiu @jpbede
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
@@ -1420,8 +1412,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/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
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
@@ -1496,8 +1486,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/subaru/ @G-Two
|
||||
/homeassistant/components/suez_water/ @ooii @jb101010-2
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/sun/ @Swamp-Ig
|
||||
/tests/components/sun/ @Swamp-Ig
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
@@ -1510,8 +1500,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switch_as_x/ @home-assistant/core
|
||||
/homeassistant/components/switchbee/ @jafar-atili
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
@@ -1551,8 +1541,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tedee/ @patrickhilker @zweckj
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @home-assistant/core
|
||||
/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
/tests/components/tesla_fleet/ @Bre77
|
||||
/homeassistant/components/tesla_wall_connector/ @einarhauks
|
||||
@@ -1688,8 +1678,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
|
||||
/tests/components/vodafone_station/ @paoloantinori @chemelli74
|
||||
/homeassistant/components/voip/ @balloob @synesthesiam @jaminh
|
||||
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||
/homeassistant/components/voip/ @balloob @synesthesiam
|
||||
/tests/components/voip/ @balloob @synesthesiam
|
||||
/homeassistant/components/volumio/ @OnFreund
|
||||
/tests/components/volumio/ @OnFreund
|
||||
/homeassistant/components/volvooncall/ @molobrakos
|
||||
@@ -1806,8 +1796,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/zeversolar/ @kvanzuijlen
|
||||
/homeassistant/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
|
||||
/tests/components/zodiac/ @JulienTant
|
||||
/homeassistant/components/zone/ @home-assistant/core
|
||||
|
||||
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{
|
||||
"domain": "amazon",
|
||||
"name": "Amazon",
|
||||
"integrations": [
|
||||
"alexa",
|
||||
"alexa_devices",
|
||||
"amazon_polly",
|
||||
"aws",
|
||||
"aws_s3",
|
||||
"fire_tv",
|
||||
"route53"
|
||||
]
|
||||
"integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"]
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "shelly",
|
||||
"name": "shelly",
|
||||
"integrations": ["shelly"],
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
@@ -67,7 +67,6 @@ POLLEN_CATEGORY_MAP = {
|
||||
2: "moderate",
|
||||
3: "high",
|
||||
4: "very_high",
|
||||
5: "extreme",
|
||||
}
|
||||
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
|
||||
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"level": {
|
||||
"name": "Level",
|
||||
"state": {
|
||||
"extreme": "Extreme",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "Moderate",
|
||||
@@ -90,7 +89,6 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||
@@ -125,7 +123,6 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||
@@ -170,7 +167,6 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||
@@ -185,7 +181,6 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||
@@ -200,7 +195,6 @@
|
||||
"level": {
|
||||
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
|
||||
"state": {
|
||||
"extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
|
||||
|
||||
@@ -40,10 +40,9 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
entry.unique_id for entry in self._async_current_entries()
|
||||
}
|
||||
|
||||
hubs: list[aiopulse.Hub] = []
|
||||
with suppress(TimeoutError):
|
||||
async with timeout(5):
|
||||
hubs = [
|
||||
hubs: list[aiopulse.Hub] = [
|
||||
hub
|
||||
async for hub in aiopulse.Hub.discover()
|
||||
if hub.id not in already_configured
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "adax",
|
||||
"name": "Adax",
|
||||
"codeowners": ["@danielhiversen", "@lazytarget"],
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
|
||||
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
@@ -52,8 +52,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
self._id: str = light["id"]
|
||||
self._attr_unique_id += f"-{self._id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
|
||||
identifiers={(ADVANTAGE_AIR_DOMAIN, self._attr_unique_id)},
|
||||
via_device=(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]),
|
||||
manufacturer="Advantage Air",
|
||||
model=light.get("moduleType"),
|
||||
name=light["name"],
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||
from .entity import AdvantageAirEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
@@ -32,7 +32,9 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
|
||||
"""Initialize the Advantage Air App."""
|
||||
super().__init__(instance)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])},
|
||||
identifiers={
|
||||
(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"])
|
||||
},
|
||||
manufacturer="Advantage Air",
|
||||
model=self.coordinator.data["system"]["sysType"],
|
||||
name=self.coordinator.data["system"]["name"],
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, SERVER_URL
|
||||
from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL
|
||||
|
||||
ATTRIBUTION = "ispyconnect.com"
|
||||
DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
|
||||
@@ -46,7 +46,7 @@ async def async_setup_entry(
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, agent_client.unique)},
|
||||
identifiers={(AGENT_DOMAIN, agent_client.unique)},
|
||||
manufacturer="iSpyConnect",
|
||||
name=f"Agent {agent_client.name}",
|
||||
model="Agent DVR",
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AgentDVRConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN as AGENT_DOMAIN
|
||||
|
||||
CONF_HOME_MODE_NAME = "home"
|
||||
CONF_AWAY_MODE_NAME = "away"
|
||||
@@ -47,7 +47,7 @@ class AgentBaseStation(AlarmControlPanelEntity):
|
||||
self._client = client
|
||||
self._attr_unique_id = f"{client.unique}_CP"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, client.unique)},
|
||||
identifiers={(AGENT_DOMAIN, client.unique)},
|
||||
name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}",
|
||||
manufacturer="Agent",
|
||||
model=CONST_ALARM_CONTROL_PANEL_NAME,
|
||||
|
||||
@@ -51,16 +51,9 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
self._current_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
|
||||
self._current_version = (
|
||||
await self.client.get_current_measures()
|
||||
).firmware_version
|
||||
|
||||
async def _async_update_data(self) -> AirGradientData:
|
||||
try:
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any, Concatenate
|
||||
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -30,7 +29,6 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
|
||||
model_id=measures.model,
|
||||
serial_number=coordinator.serial_number,
|
||||
sw_version=measures.firmware_version,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,19 +3,6 @@
|
||||
"name": "Airthings",
|
||||
"codeowners": ["@danielhiversen", "@LaStrada"],
|
||||
"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",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["airthings"],
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS,
|
||||
EntityCategory,
|
||||
@@ -79,12 +78,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="light",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"lux": SensorEntityDescription(
|
||||
key="lux",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"virusRisk": SensorEntityDescription(
|
||||
key="virusRisk",
|
||||
translation_key="virus_risk",
|
||||
|
||||
@@ -142,7 +142,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
return AT_TO_HA_STATE[self._airtouch.acs[self._ac_number].AcMode]
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
def hvac_modes(self):
|
||||
"""Return the list of available operation modes."""
|
||||
airtouch_modes = self._airtouch.GetSupportedCoolingModesForAc(self._ac_number)
|
||||
modes = [AT_TO_HA_STATE[mode] for mode in airtouch_modes]
|
||||
@@ -226,12 +226,12 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
def min_temp(self):
|
||||
"""Return Minimum Temperature for AC of this group."""
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MinSetpoint
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
def max_temp(self):
|
||||
"""Return Max Temperature for AC of this group."""
|
||||
return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose AlarmDecoder protocol",
|
||||
"title": "Choose AlarmDecoder Protocol",
|
||||
"data": {
|
||||
"protocol": "Protocol"
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"device_baudrate": "Device baud rate",
|
||||
"device_path": "Device path"
|
||||
"device_baudrate": "Device Baud Rate",
|
||||
"device_path": "Device Path"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
|
||||
@@ -44,36 +44,36 @@
|
||||
"arm_settings": {
|
||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||
"data": {
|
||||
"auto_bypass": "Auto-bypass on arm",
|
||||
"code_arm_required": "Code required for arming",
|
||||
"alt_night_mode": "Alternative night mode"
|
||||
"auto_bypass": "Auto Bypass on Arm",
|
||||
"code_arm_required": "Code Required for Arming",
|
||||
"alt_night_mode": "Alternative Night Mode"
|
||||
}
|
||||
},
|
||||
"zone_select": {
|
||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||
"description": "Enter the zone number you'd like to to add, edit, or remove.",
|
||||
"data": {
|
||||
"zone_number": "Zone number"
|
||||
"zone_number": "Zone Number"
|
||||
}
|
||||
},
|
||||
"zone_details": {
|
||||
"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": {
|
||||
"zone_name": "Zone name",
|
||||
"zone_type": "Zone type",
|
||||
"zone_rfid": "RF serial",
|
||||
"zone_loop": "RF loop",
|
||||
"zone_relayaddr": "Relay address",
|
||||
"zone_relaychan": "Relay channel"
|
||||
"zone_name": "Zone Name",
|
||||
"zone_type": "Zone Type",
|
||||
"zone_rfid": "RF Serial",
|
||||
"zone_loop": "RF Loop",
|
||||
"zone_relayaddr": "Relay Address",
|
||||
"zone_relaychan": "Relay Channel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"loop_rfid": "'RF loop' cannot be used without 'RF serial'.",
|
||||
"loop_range": "'RF loop' must be an integer between 1 and 4."
|
||||
"loop_rfid": "RF Loop cannot be used without RF Serial.",
|
||||
"loop_range": "RF Loop must be an integer between 1 and 4."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,74 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Alexa Devices constants."""
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "alexa_devices"
|
||||
CONF_LOGIN_DATA = "login_data"
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"default": "mdi:bluetooth",
|
||||
"state": {
|
||||
"off": "mdi:bluetooth-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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.1.2"]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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 aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
||||
|
||||
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."""
|
||||
|
||||
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
|
||||
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
||||
subkey: str
|
||||
|
||||
|
||||
NOTIFY: Final = (
|
||||
AmazonNotifyEntityDescription(
|
||||
key="speak",
|
||||
translation_key="speak",
|
||||
subkey="AUDIO_PLAYER",
|
||||
is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY,
|
||||
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
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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)
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.2.2"],
|
||||
"requirements": ["androidtvremote2==0.2.1"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -51,10 +51,6 @@
|
||||
"app_id": "Application ID",
|
||||
"app_icon": "Application icon",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,4 @@ CONF_THINKING_BUDGET = "thinking_budget"
|
||||
RECOMMENDED_THINKING_BUDGET = 0
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
|
||||
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",
|
||||
]
|
||||
THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"]
|
||||
|
||||
@@ -294,8 +294,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
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):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
@@ -328,7 +326,6 @@ class AnthropicConversationEntity(
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.52.0"]
|
||||
"requirements": ["anthropic==0.47.2"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(step_id="user", data_schema=_SCHEMA)
|
||||
|
||||
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})
|
||||
|
||||
# Test the connection to the host and get the current status for serial number.
|
||||
try:
|
||||
async with asyncio.timeout(CONNECTION_TIMEOUT):
|
||||
data = APCUPSdData(await aioapcaccess.request_status(host, port))
|
||||
@@ -63,30 +67,3 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
title = data.name or data.model or data.serial_no or "APC UPS"
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
|
||||
@@ -62,8 +62,6 @@ async def async_setup_entry(
|
||||
target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT,
|
||||
min_humidity=10,
|
||||
max_humidity=50,
|
||||
auto_status_key=Attribute.HUMIDIFICATION_AVAILABLE,
|
||||
auto_status_value=1,
|
||||
default_humidity=30,
|
||||
set_humidity_fn=coordinator.client.set_humidification_setpoint,
|
||||
)
|
||||
@@ -79,8 +77,6 @@ async def async_setup_entry(
|
||||
action_map=DEHUMIDIFIER_ACTION_MAP,
|
||||
current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE,
|
||||
target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT,
|
||||
auto_status_key=None,
|
||||
auto_status_value=None,
|
||||
min_humidity=40,
|
||||
max_humidity=90,
|
||||
default_humidity=60,
|
||||
@@ -104,8 +100,6 @@ class AprilaireHumidifierDescription(HumidifierEntityDescription):
|
||||
target_humidity_key: str
|
||||
min_humidity: int
|
||||
max_humidity: int
|
||||
auto_status_key: str | None
|
||||
auto_status_value: int | None
|
||||
default_humidity: int
|
||||
set_humidity_fn: Callable[[int], Awaitable]
|
||||
|
||||
@@ -169,31 +163,14 @@ class AprilaireHumidifierEntity(BaseAprilaireEntity, HumidifierEntity):
|
||||
def min_humidity(self) -> float:
|
||||
"""Return the minimum humidity."""
|
||||
|
||||
if self.is_auto_humidity_mode():
|
||||
return 1
|
||||
|
||||
return self.entity_description.min_humidity
|
||||
|
||||
@property
|
||||
def max_humidity(self) -> float:
|
||||
"""Return the maximum humidity."""
|
||||
|
||||
if self.is_auto_humidity_mode():
|
||||
return 7
|
||||
|
||||
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:
|
||||
"""Set the humidity."""
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyaprilaire"],
|
||||
"requirements": ["pyaprilaire==0.9.1"]
|
||||
"requirements": ["pyaprilaire==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["APsystemsEZ1"],
|
||||
"requirements": ["apsystems-ez1==2.7.0"]
|
||||
"requirements": ["apsystems-ez1==2.6.0"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_update": {
|
||||
"default": "mdi:update"
|
||||
},
|
||||
"salt_left_side_percentage": {
|
||||
"default": "mdi:basket-fill"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from aioaquacell import Softener
|
||||
|
||||
@@ -29,7 +28,7 @@ PARALLEL_UPDATES = 1
|
||||
class SoftenerSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Softener sensor entity."""
|
||||
|
||||
value_fn: Callable[[Softener], StateType | datetime]
|
||||
value_fn: Callable[[Softener], StateType]
|
||||
|
||||
|
||||
SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
|
||||
@@ -78,12 +77,6 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
|
||||
"low",
|
||||
],
|
||||
),
|
||||
SoftenerSensorEntityDescription(
|
||||
key="last_update",
|
||||
translation_key="last_update",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda softener: softener.lastUpdate,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -118,6 +111,6 @@ class SoftenerSensor(AquacellEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.softener)
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_update": {
|
||||
"name": "Last update"
|
||||
},
|
||||
"salt_left_side_percentage": {
|
||||
"name": "Salt left side percentage"
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||
def get_aruba_data(self) -> dict[str, dict[str, str]] | None:
|
||||
"""Retrieve data from Aruba Access Point and return parsed result."""
|
||||
|
||||
connect = f"ssh {self.username}@{self.host}"
|
||||
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
|
||||
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
|
||||
query = ssh.expect(
|
||||
[
|
||||
|
||||
@@ -20,6 +20,9 @@ import hass_nabucasa
|
||||
import voluptuous as vol
|
||||
|
||||
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.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -89,8 +92,6 @@ KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
|
||||
KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey(
|
||||
"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:
|
||||
@@ -554,7 +555,7 @@ class PipelineRun:
|
||||
event_callback: PipelineEventCallback
|
||||
language: str = None # type: ignore[assignment]
|
||||
runner_data: Any | None = None
|
||||
intent_agent: conversation.AgentInfo | None = None
|
||||
intent_agent: str | None = None
|
||||
tts_audio_output: str | dict[str, Any] | None = None
|
||||
wake_word_settings: WakeWordSettings | None = None
|
||||
audio_settings: AudioSettings = field(default_factory=AudioSettings)
|
||||
@@ -590,9 +591,6 @@ class PipelineRun:
|
||||
_intent_agent_only = False
|
||||
"""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:
|
||||
"""Set language for pipeline."""
|
||||
self.language = self.pipeline.language or self.hass.config.language
|
||||
@@ -654,11 +652,6 @@ class PipelineRun:
|
||||
"token": self.tts_stream.token,
|
||||
"url": self.tts_stream.url,
|
||||
"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))
|
||||
@@ -906,12 +899,12 @@ class PipelineRun:
|
||||
) -> str:
|
||||
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
|
||||
# Create a background task to prepare the conversation agent
|
||||
if self.end_stage >= PipelineStage.INTENT and self.intent_agent:
|
||||
if self.end_stage >= PipelineStage.INTENT:
|
||||
self.hass.async_create_background_task(
|
||||
conversation.async_prepare_agent(
|
||||
self.hass, self.intent_agent.id, self.language
|
||||
self.hass, self.intent_agent, self.language
|
||||
),
|
||||
f"prepare conversation agent {self.intent_agent.id}",
|
||||
f"prepare conversation agent {self.intent_agent}",
|
||||
)
|
||||
|
||||
if isinstance(self.stt_provider, stt.Provider):
|
||||
@@ -1052,7 +1045,7 @@ class PipelineRun:
|
||||
message=f"Intent recognition engine {engine} is not found",
|
||||
)
|
||||
|
||||
self.intent_agent = agent_info
|
||||
self.intent_agent = agent_info.id
|
||||
|
||||
async def recognize_intent(
|
||||
self,
|
||||
@@ -1085,7 +1078,7 @@ class PipelineRun:
|
||||
PipelineEvent(
|
||||
PipelineEventType.INTENT_START,
|
||||
{
|
||||
"engine": self.intent_agent.id,
|
||||
"engine": self.intent_agent,
|
||||
"language": input_language,
|
||||
"intent_input": intent_input,
|
||||
"conversation_id": conversation_id,
|
||||
@@ -1102,11 +1095,11 @@ class PipelineRun:
|
||||
conversation_id=conversation_id,
|
||||
device_id=device_id,
|
||||
language=input_language,
|
||||
agent_id=self.intent_agent.id,
|
||||
agent_id=self.intent_agent,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
|
||||
agent_id = self.intent_agent.id
|
||||
agent_id = self.intent_agent
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if not processed_locally and not self._intent_agent_only:
|
||||
@@ -1128,7 +1121,7 @@ class PipelineRun:
|
||||
# If the LLM has API access, we filter out some sentences that are
|
||||
# interfering with LLM operation.
|
||||
if (
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent.id)
|
||||
intent_agent_state := self.hass.states.get(self.intent_agent)
|
||||
) and intent_agent_state.attributes.get(
|
||||
ATTR_SUPPORTED_FEATURES, 0
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
@@ -1150,13 +1143,6 @@ class PipelineRun:
|
||||
agent_id = conversation.HOME_ASSISTANT_AGENT
|
||||
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
|
||||
def chat_log_delta_listener(
|
||||
chat_log: conversation.ChatLog, delta: dict
|
||||
@@ -1170,61 +1156,6 @@ 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 (
|
||||
chat_session.async_get_chat_session(
|
||||
@@ -1268,8 +1199,6 @@ class PipelineRun:
|
||||
speech = conversation_result.response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
)
|
||||
if tts_input_stream and self._streamed_response_text:
|
||||
tts_input_stream.put_nowait(None)
|
||||
|
||||
except Exception as src_error:
|
||||
_LOGGER.exception("Unexpected error during intent recognition")
|
||||
@@ -1347,11 +1276,26 @@ class PipelineRun:
|
||||
)
|
||||
)
|
||||
|
||||
if not self._streamed_response_text:
|
||||
self.tts_stream.async_set_message(tts_input)
|
||||
try:
|
||||
# Synthesize audio and get URL
|
||||
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 = {
|
||||
"media_id": self.tts_stream.media_source_id,
|
||||
"media_id": tts_media_id,
|
||||
"token": self.tts_stream.token,
|
||||
"url": self.tts_stream.url,
|
||||
"mime_type": self.tts_stream.content_type,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"step": {
|
||||
"validation": {
|
||||
"title": "Two-factor authentication",
|
||||
"title": "Two factor authentication",
|
||||
"data": {
|
||||
"verification_code": "Verification code"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"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",
|
||||
"data": {
|
||||
"port": "RS485 or USB-RS485 adaptor port",
|
||||
"address": "Inverter address"
|
||||
"port": "RS485 or USB-RS485 Adaptor Port",
|
||||
"address": "Inverter Address"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_serial_ports": "No com ports found. The integration needs a valid RS485 device to communicate."
|
||||
"no_serial_ports": "No com ports found. Need a valid RS485 device to communicate."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"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": {
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"title": "Notify one-time password",
|
||||
"title": "Notify One-Time Password",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Set up one-time password delivered by notify component",
|
||||
|
||||
@@ -47,7 +47,7 @@ from .const import (
|
||||
CONF_VIDEO_SOURCE,
|
||||
DEFAULT_STREAM_PROFILE,
|
||||
DEFAULT_VIDEO_SOURCE,
|
||||
DOMAIN,
|
||||
DOMAIN as AXIS_DOMAIN,
|
||||
)
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
@@ -58,7 +58,7 @@ DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
|
||||
class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
"""Handle a Axis config flow."""
|
||||
|
||||
VERSION = 3
|
||||
@@ -146,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
model = self.config[CONF_MODEL]
|
||||
same_model = [
|
||||
entry.data[CONF_NAME]
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN)
|
||||
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN as AXIS_DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .hub import AxisHub
|
||||
@@ -61,7 +61,7 @@ class AxisEntity(Entity):
|
||||
self.hub = hub
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, hub.unique_id)},
|
||||
identifiers={(AXIS_DOMAIN, hub.unique_id)},
|
||||
serial_number=hub.unique_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from azure.core.exceptions import (
|
||||
AzureError,
|
||||
ClientAuthenticationError,
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
@@ -39,20 +39,11 @@ async def async_setup_entry(
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
"""Create a ContainerClient."""
|
||||
|
||||
return ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
# has a blocking call to open in cpython
|
||||
container_client: ContainerClient = await hass.async_add_executor_job(
|
||||
create_container_client
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -70,7 +61,7 @@ async def async_setup_entry(
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except AzureError as err:
|
||||
except HttpResponseError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from azure.core.exceptions import AzureError, HttpResponseError, ServiceRequestError
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.storage.blob import BlobProperties
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
@@ -80,20 +80,6 @@ def handle_backup_errors[_R, **P](
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
except ServiceRequestError as err:
|
||||
raise BackupAgentError(
|
||||
f"Timeout during backup operation in {func.__name__}"
|
||||
) from err
|
||||
except AzureError as err:
|
||||
_LOGGER.debug(
|
||||
"Error during backup in %s: %s",
|
||||
func.__name__,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}: {err}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -27,25 +27,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
async def get_container_client(
|
||||
self, account_name: str, container_name: str, storage_account_key: str
|
||||
) -> ContainerClient:
|
||||
"""Get the container client.
|
||||
|
||||
ContainerClient has a blocking call to open in cpython
|
||||
"""
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
def create_container_client() -> ContainerClient:
|
||||
return ContainerClient(
|
||||
account_url=f"https://{account_name}.blob.core.windows.net/",
|
||||
container_name=container_name,
|
||||
credential=storage_account_key,
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
return await self.hass.async_add_executor_job(create_container_client)
|
||||
def get_account_url(self, account_name: str) -> str:
|
||||
"""Get the account URL."""
|
||||
return f"https://{account_name}.blob.core.windows.net/"
|
||||
|
||||
async def validate_config(
|
||||
self, container_client: ContainerClient
|
||||
@@ -74,10 +58,11 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = await self.get_container_client(
|
||||
account_name=user_input[CONF_ACCOUNT_NAME],
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]),
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
|
||||
@@ -114,12 +99,12 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reauth_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]),
|
||||
container_name=reauth_entry.data[CONF_CONTAINER_NAME],
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -144,10 +129,13 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
container_client = await self.get_container_client(
|
||||
account_name=reconfigure_entry.data[CONF_ACCOUNT_NAME],
|
||||
container_client = ContainerClient(
|
||||
account_url=self.get_account_url(
|
||||
reconfigure_entry.data[CONF_ACCOUNT_NAME]
|
||||
),
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
errors = await self.validate_config(container_client)
|
||||
if not errors:
|
||||
|
||||
@@ -23,7 +23,6 @@ from .const import DATA_MANAGER, DOMAIN
|
||||
from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
|
||||
from .http import async_register_http_views
|
||||
from .manager import (
|
||||
AddonErrorData,
|
||||
BackupManager,
|
||||
BackupManagerError,
|
||||
BackupPlatformEvent,
|
||||
@@ -49,7 +48,6 @@ from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
__all__ = [
|
||||
"AddonErrorData",
|
||||
"AddonInfo",
|
||||
"AgentBackup",
|
||||
"BackupAgent",
|
||||
@@ -81,7 +79,7 @@ __all__ = [
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@@ -30,10 +30,8 @@ class BackupCoordinatorData:
|
||||
"""Class to hold backup data."""
|
||||
|
||||
backup_manager_state: BackupManagerState
|
||||
last_attempted_automatic_backup: datetime | None
|
||||
last_successful_automatic_backup: datetime | None
|
||||
next_scheduled_automatic_backup: datetime | None
|
||||
last_event: ManagerStateEvent | BackupPlatformEvent | None
|
||||
|
||||
|
||||
class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
||||
@@ -61,23 +59,19 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
||||
]
|
||||
|
||||
self.backup_manager = backup_manager
|
||||
self._last_event: ManagerStateEvent | BackupPlatformEvent | None = None
|
||||
|
||||
@callback
|
||||
def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
|
||||
"""Handle new event."""
|
||||
LOGGER.debug("Received backup event: %s", event)
|
||||
self._last_event = event
|
||||
self.config_entry.async_create_task(self.hass, self.async_refresh())
|
||||
|
||||
async def _async_update_data(self) -> BackupCoordinatorData:
|
||||
"""Update backup manager data."""
|
||||
return BackupCoordinatorData(
|
||||
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.schedule.next_automatic_backup,
|
||||
self._last_event,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -11,7 +11,7 @@ from .const import DOMAIN
|
||||
from .coordinator import BackupDataUpdateCoordinator
|
||||
|
||||
|
||||
class BackupManagerBaseEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
|
||||
class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
|
||||
"""Base entity for backup manager."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -19,9 +19,12 @@ class BackupManagerBaseEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BackupDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize base entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = entity_description.key
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, "backup_manager")},
|
||||
manufacturer="Home Assistant",
|
||||
@@ -31,17 +34,3 @@ class BackupManagerBaseEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
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
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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()
|
||||
@@ -22,7 +22,7 @@ from . import util
|
||||
from .agent import BackupAgent
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import BackupManager
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import BackupNotFound
|
||||
|
||||
|
||||
@callback
|
||||
@@ -85,15 +85,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
request, headers, backup_id, agent_id, agent, manager
|
||||
)
|
||||
return await self._send_backup_with_password(
|
||||
hass,
|
||||
backup,
|
||||
request,
|
||||
headers,
|
||||
backup_id,
|
||||
agent_id,
|
||||
password,
|
||||
agent,
|
||||
manager,
|
||||
hass, request, headers, backup_id, agent_id, password, agent, manager
|
||||
)
|
||||
except BackupNotFound:
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
@@ -124,7 +116,6 @@ class DownloadBackupView(HomeAssistantView):
|
||||
async def _send_backup_with_password(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
backup: AgentBackup,
|
||||
request: Request,
|
||||
headers: dict[istr, str],
|
||||
backup_id: str,
|
||||
@@ -153,8 +144,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
|
||||
stream = util.AsyncIteratorWriter(hass)
|
||||
worker = threading.Thread(
|
||||
target=util.decrypt_backup,
|
||||
args=[backup, reader, stream, password, on_done, 0, []],
|
||||
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
|
||||
)
|
||||
try:
|
||||
worker.start()
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"automatic_backup_event": {
|
||||
"default": "mdi:database"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"create": {
|
||||
"service": "mdi:cloud-upload"
|
||||
|
||||
@@ -62,7 +62,6 @@ from .const import (
|
||||
LOGGER,
|
||||
)
|
||||
from .models import (
|
||||
AddonInfo,
|
||||
AgentBackup,
|
||||
BackupError,
|
||||
BackupManagerError,
|
||||
@@ -103,27 +102,15 @@ class ManagerBackup(BaseBackup):
|
||||
"""Backup class."""
|
||||
|
||||
agents: dict[str, AgentBackupStatus]
|
||||
failed_addons: list[AddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[Folder]
|
||||
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)
|
||||
class WrittenBackup:
|
||||
"""Written backup class."""
|
||||
|
||||
addon_errors: dict[str, AddonErrorData]
|
||||
backup: AgentBackup
|
||||
folder_errors: dict[Folder, list[tuple[str, str]]]
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]]
|
||||
release_stream: Callable[[], Coroutine[Any, Any, None]]
|
||||
|
||||
@@ -649,13 +636,9 @@ class BackupManager:
|
||||
for agent_backup in result:
|
||||
if (backup_id := agent_backup.backup_id) not in backups:
|
||||
if known_backup := self.known_backups.get(backup_id):
|
||||
failed_addons = known_backup.failed_addons
|
||||
failed_agent_ids = known_backup.failed_agent_ids
|
||||
failed_folders = known_backup.failed_folders
|
||||
else:
|
||||
failed_addons = []
|
||||
failed_agent_ids = []
|
||||
failed_folders = []
|
||||
with_automatic_settings = self.is_our_automatic_backup(
|
||||
agent_backup, await instance_id.async_get(self.hass)
|
||||
)
|
||||
@@ -666,9 +649,7 @@ class BackupManager:
|
||||
date=agent_backup.date,
|
||||
database_included=agent_backup.database_included,
|
||||
extra_metadata=agent_backup.extra_metadata,
|
||||
failed_addons=failed_addons,
|
||||
failed_agent_ids=failed_agent_ids,
|
||||
failed_folders=failed_folders,
|
||||
folders=agent_backup.folders,
|
||||
homeassistant_included=agent_backup.homeassistant_included,
|
||||
homeassistant_version=agent_backup.homeassistant_version,
|
||||
@@ -723,13 +704,9 @@ class BackupManager:
|
||||
continue
|
||||
if backup is None:
|
||||
if known_backup := self.known_backups.get(backup_id):
|
||||
failed_addons = known_backup.failed_addons
|
||||
failed_agent_ids = known_backup.failed_agent_ids
|
||||
failed_folders = known_backup.failed_folders
|
||||
else:
|
||||
failed_addons = []
|
||||
failed_agent_ids = []
|
||||
failed_folders = []
|
||||
with_automatic_settings = self.is_our_automatic_backup(
|
||||
result, await instance_id.async_get(self.hass)
|
||||
)
|
||||
@@ -740,9 +717,7 @@ class BackupManager:
|
||||
date=result.date,
|
||||
database_included=result.database_included,
|
||||
extra_metadata=result.extra_metadata,
|
||||
failed_addons=failed_addons,
|
||||
failed_agent_ids=failed_agent_ids,
|
||||
failed_folders=failed_folders,
|
||||
folders=result.folders,
|
||||
homeassistant_included=result.homeassistant_included,
|
||||
homeassistant_version=result.homeassistant_version,
|
||||
@@ -985,7 +960,7 @@ class BackupManager:
|
||||
password=None,
|
||||
)
|
||||
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
|
||||
|
||||
async def async_create_backup(
|
||||
@@ -1223,11 +1198,7 @@ class BackupManager:
|
||||
finally:
|
||||
await written_backup.release_stream()
|
||||
self.known_backups.add(
|
||||
written_backup.backup,
|
||||
agent_errors,
|
||||
written_backup.addon_errors,
|
||||
written_backup.folder_errors,
|
||||
unavailable_agents,
|
||||
written_backup.backup, agent_errors, unavailable_agents
|
||||
)
|
||||
if not agent_errors:
|
||||
if with_automatic_settings:
|
||||
@@ -1237,9 +1208,7 @@ class BackupManager:
|
||||
backup_success = True
|
||||
|
||||
if with_automatic_settings:
|
||||
self._update_issue_after_agent_upload(
|
||||
written_backup, agent_errors, unavailable_agents
|
||||
)
|
||||
self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
@@ -1385,10 +1354,8 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
def _create_automatic_backup_failed_issue(
|
||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||
) -> None:
|
||||
"""Create an issue in the issue registry for automatic backup failures."""
|
||||
def _update_issue_backup_failed(self) -> None:
|
||||
"""Update issue registry when a backup fails."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
@@ -1397,73 +1364,37 @@ class BackupManager:
|
||||
is_persistent=True,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
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
|
||||
translation_key="automatic_backup_failed_create",
|
||||
)
|
||||
|
||||
def _update_issue_after_agent_upload(
|
||||
self,
|
||||
written_backup: WrittenBackup,
|
||||
agent_errors: dict[str, Exception],
|
||||
unavailable_agents: list[str],
|
||||
self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
|
||||
) -> None:
|
||||
"""Update issue registry after a backup is uploaded to 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
|
||||
if not agent_errors and not unavailable_agents:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
|
||||
return
|
||||
if failed_agents and not (addon_errors or folder_errors):
|
||||
# No issues with add-ons or folders, but issues with agents
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_upload_agents",
|
||||
{"failed_agents": ", ".join(failed_agents)},
|
||||
)
|
||||
elif addon_errors and not (failed_agents or folder_errors):
|
||||
# No issues with agents or folders, but issues with add-ons
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_addons",
|
||||
{
|
||||
"failed_addons": ", ".join(
|
||||
val.addon.name or val.addon.slug
|
||||
for val in addon_errors.values()
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"automatic_backup_failed",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_failed_upload_agents",
|
||||
translation_placeholders={
|
||||
"failed_agents": ", ".join(
|
||||
chain(
|
||||
(
|
||||
self.backup_agents[agent_id].name
|
||||
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(
|
||||
self,
|
||||
@@ -1529,12 +1460,7 @@ class KnownBackups:
|
||||
self._backups = {
|
||||
backup["backup_id"]: KnownBackup(
|
||||
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_folders=[Folder(f) for f in backup["failed_folders"]],
|
||||
)
|
||||
for backup in stored_backups
|
||||
}
|
||||
@@ -1547,16 +1473,12 @@ class KnownBackups:
|
||||
self,
|
||||
backup: AgentBackup,
|
||||
agent_errors: dict[str, Exception],
|
||||
failed_addons: dict[str, AddonErrorData],
|
||||
failed_folders: dict[Folder, list[tuple[str, str]]],
|
||||
unavailable_agents: list[str],
|
||||
) -> None:
|
||||
"""Add a backup."""
|
||||
self._backups[backup.backup_id] = KnownBackup(
|
||||
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_folders=list(failed_folders),
|
||||
)
|
||||
self._manager.store.save()
|
||||
|
||||
@@ -1577,38 +1499,21 @@ class KnownBackup:
|
||||
"""Persistent backup data."""
|
||||
|
||||
backup_id: str
|
||||
failed_addons: list[AddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[Folder]
|
||||
|
||||
def to_dict(self) -> StoredKnownBackup:
|
||||
"""Convert known backup to a dict."""
|
||||
return {
|
||||
"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_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):
|
||||
"""Stored persistent backup data."""
|
||||
|
||||
backup_id: str
|
||||
failed_addons: list[StoredAddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[str]
|
||||
|
||||
|
||||
class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
@@ -1772,11 +1677,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
|
||||
return WrittenBackup(
|
||||
addon_errors={},
|
||||
backup=backup,
|
||||
folder_errors={},
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
backup=backup, open_stream=open_backup, release_stream=remove_backup
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
@@ -1915,11 +1816,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
await async_add_executor_job(temp_file.unlink, True)
|
||||
|
||||
return WrittenBackup(
|
||||
addon_errors={},
|
||||
backup=backup,
|
||||
folder_errors={},
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
backup=backup, open_stream=open_backup, release_stream=remove_backup
|
||||
)
|
||||
|
||||
async def async_restore_backup(
|
||||
|
||||
@@ -13,9 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
class AddonInfo:
|
||||
"""Addon information."""
|
||||
|
||||
name: str | None
|
||||
name: str
|
||||
slug: str
|
||||
version: str | None
|
||||
version: str
|
||||
|
||||
|
||||
class Folder(StrEnum):
|
||||
|
||||
@@ -46,12 +46,6 @@ BACKUP_MANAGER_DESCRIPTIONS = (
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 7
|
||||
STORAGE_VERSION_MINOR = 6
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -76,16 +76,8 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
# Version 1.6 adds agent retention settings
|
||||
for agent in data["config"]["agents"]:
|
||||
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 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.
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Reject if major version is higher than 2.
|
||||
if old_major_version > 2:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -11,18 +11,6 @@
|
||||
"automatic_backup_failed_upload_agents": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
@@ -36,22 +24,6 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"backup_manager_state": {
|
||||
"name": "Backup Manager state",
|
||||
@@ -65,9 +37,6 @@
|
||||
"next_scheduled_automatic_backup": {
|
||||
"name": "Next scheduled automatic backup"
|
||||
},
|
||||
"last_attempted_automatic_backup": {
|
||||
"name": "Last attempted automatic backup"
|
||||
},
|
||||
"last_successful_automatic_backup": {
|
||||
"name": "Last successful automatic backup"
|
||||
}
|
||||
|
||||
@@ -295,26 +295,13 @@ def validate_password_stream(
|
||||
raise BackupEmpty
|
||||
|
||||
|
||||
def _get_expected_archives(backup: AgentBackup) -> set[str]:
|
||||
"""Get the expected archives in the backup."""
|
||||
expected_archives = set()
|
||||
if backup.homeassistant_included:
|
||||
expected_archives.add("homeassistant")
|
||||
for addon in backup.addons:
|
||||
expected_archives.add(addon.slug)
|
||||
for folder in backup.folders:
|
||||
expected_archives.add(folder.value)
|
||||
return expected_archives
|
||||
|
||||
|
||||
def decrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_stream: IO[bytes],
|
||||
output_stream: IO[bytes],
|
||||
password: str | None,
|
||||
on_done: Callable[[Exception | None], None],
|
||||
minimum_size: int,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Decrypt a backup."""
|
||||
error: Exception | None = None
|
||||
@@ -328,13 +315,10 @@ def decrypt_backup(
|
||||
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||
) as output_tar,
|
||||
):
|
||||
_decrypt_backup(backup, input_tar, output_tar, password)
|
||||
_decrypt_backup(input_tar, output_tar, password)
|
||||
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error decrypting backup: %s", err)
|
||||
error = err
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
padding = max(minimum_size - output_stream.tell(), 0)
|
||||
@@ -349,18 +333,15 @@ def decrypt_backup(
|
||||
|
||||
|
||||
def _decrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_tar: tarfile.TarFile,
|
||||
output_tar: tarfile.TarFile,
|
||||
password: str | None,
|
||||
) -> None:
|
||||
"""Decrypt a backup."""
|
||||
expected_archives = _get_expected_archives(backup)
|
||||
for obj in input_tar:
|
||||
# We compare with PurePath to avoid issues with different path separators,
|
||||
# for example when backup.json is added as "./backup.json"
|
||||
object_path = PurePath(obj.name)
|
||||
if object_path == PurePath("backup.json"):
|
||||
if PurePath(obj.name) == PurePath("backup.json"):
|
||||
# Rewrite the backup.json file to indicate that the backup is decrypted
|
||||
if not (reader := input_tar.extractfile(obj)):
|
||||
raise DecryptError
|
||||
@@ -371,13 +352,7 @@ def _decrypt_backup(
|
||||
metadata_obj.size = len(updated_metadata_b)
|
||||
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
|
||||
continue
|
||||
prefix, _, suffix = object_path.name.partition(".")
|
||||
if suffix not in ("tar", "tgz", "tar.gz"):
|
||||
LOGGER.debug("Unknown file %s will not be decrypted", obj.name)
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
if prefix not in expected_archives:
|
||||
LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name)
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
istf = SecureTarFile(
|
||||
@@ -396,13 +371,12 @@ def _decrypt_backup(
|
||||
|
||||
|
||||
def encrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_stream: IO[bytes],
|
||||
output_stream: IO[bytes],
|
||||
password: str | None,
|
||||
on_done: Callable[[Exception | None], None],
|
||||
minimum_size: int,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Encrypt a backup."""
|
||||
error: Exception | None = None
|
||||
@@ -416,13 +390,10 @@ def encrypt_backup(
|
||||
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||
) as output_tar,
|
||||
):
|
||||
_encrypt_backup(backup, input_tar, output_tar, password, nonces)
|
||||
_encrypt_backup(input_tar, output_tar, password, nonces)
|
||||
except (EncryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error encrypting backup: %s", err)
|
||||
error = err
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
padding = max(minimum_size - output_stream.tell(), 0)
|
||||
@@ -437,20 +408,17 @@ def encrypt_backup(
|
||||
|
||||
|
||||
def _encrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_tar: tarfile.TarFile,
|
||||
output_tar: tarfile.TarFile,
|
||||
password: str | None,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Encrypt a backup."""
|
||||
inner_tar_idx = 0
|
||||
expected_archives = _get_expected_archives(backup)
|
||||
for obj in input_tar:
|
||||
# We compare with PurePath to avoid issues with different path separators,
|
||||
# for example when backup.json is added as "./backup.json"
|
||||
object_path = PurePath(obj.name)
|
||||
if object_path == PurePath("backup.json"):
|
||||
if PurePath(obj.name) == PurePath("backup.json"):
|
||||
# Rewrite the backup.json file to indicate that the backup is encrypted
|
||||
if not (reader := input_tar.extractfile(obj)):
|
||||
raise EncryptError
|
||||
@@ -461,21 +429,16 @@ def _encrypt_backup(
|
||||
metadata_obj.size = len(updated_metadata_b)
|
||||
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
|
||||
continue
|
||||
prefix, _, suffix = object_path.name.partition(".")
|
||||
if suffix not in ("tar", "tgz", "tar.gz"):
|
||||
LOGGER.debug("Unknown file %s will not be encrypted", obj.name)
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
if prefix not in expected_archives:
|
||||
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
|
||||
continue
|
||||
istf = SecureTarFile(
|
||||
None, # Not used
|
||||
gzip=False,
|
||||
key=password_to_key(password) if password is not None else None,
|
||||
mode="r",
|
||||
fileobj=input_tar.extractfile(obj),
|
||||
nonce=nonces.get(inner_tar_idx),
|
||||
nonce=nonces[inner_tar_idx],
|
||||
)
|
||||
inner_tar_idx += 1
|
||||
with istf.encrypt(obj) as encrypted:
|
||||
@@ -493,33 +456,17 @@ class _CipherWorkerStatus:
|
||||
writer: AsyncIteratorWriter
|
||||
|
||||
|
||||
class NonceGenerator:
|
||||
"""Generate nonces for encryption."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the generator."""
|
||||
self._nonces: dict[int, bytes] = {}
|
||||
|
||||
def get(self, index: int) -> bytes:
|
||||
"""Get a nonce for the given index."""
|
||||
if index not in self._nonces:
|
||||
# Generate a new nonce for the given index
|
||||
self._nonces[index] = os.urandom(16)
|
||||
return self._nonces[index]
|
||||
|
||||
|
||||
class _CipherBackupStreamer:
|
||||
"""Encrypt or decrypt a backup."""
|
||||
|
||||
_cipher_func: Callable[
|
||||
[
|
||||
AgentBackup,
|
||||
IO[bytes],
|
||||
IO[bytes],
|
||||
str | None,
|
||||
Callable[[Exception | None], None],
|
||||
int,
|
||||
NonceGenerator,
|
||||
list[bytes],
|
||||
],
|
||||
None,
|
||||
]
|
||||
@@ -537,7 +484,7 @@ class _CipherBackupStreamer:
|
||||
self._hass = hass
|
||||
self._open_stream = open_stream
|
||||
self._password = password
|
||||
self._nonces = NonceGenerator()
|
||||
self._nonces: list[bytes] = []
|
||||
|
||||
def size(self) -> int:
|
||||
"""Return the maximum size of the decrypted or encrypted backup."""
|
||||
@@ -561,15 +508,7 @@ class _CipherBackupStreamer:
|
||||
writer = AsyncIteratorWriter(self._hass)
|
||||
worker = threading.Thread(
|
||||
target=self._cipher_func,
|
||||
args=[
|
||||
self._backup,
|
||||
reader,
|
||||
writer,
|
||||
self._password,
|
||||
on_done,
|
||||
self.size(),
|
||||
self._nonces,
|
||||
],
|
||||
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
|
||||
)
|
||||
worker_status = _CipherWorkerStatus(
|
||||
done=asyncio.Event(), reader=reader, thread=worker, writer=writer
|
||||
@@ -599,6 +538,17 @@ class DecryptedBackupStreamer(_CipherBackupStreamer):
|
||||
class EncryptedBackupStreamer(_CipherBackupStreamer):
|
||||
"""Encrypt a backup."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
backup: AgentBackup,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
password: str | None,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(hass, backup, open_stream, password)
|
||||
self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())]
|
||||
|
||||
_cipher_func = staticmethod(encrypt_backup)
|
||||
|
||||
def backup(self) -> AgentBackup:
|
||||
|
||||
@@ -21,6 +21,7 @@ from .entity import BleBoxEntity
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
None: None,
|
||||
0: HVACMode.OFF,
|
||||
1: HVACMode.HEAT,
|
||||
2: HVACMode.COOL,
|
||||
@@ -58,14 +59,12 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
def hvac_modes(self):
|
||||
"""Return list of supported HVAC modes."""
|
||||
if self._feature.mode is None:
|
||||
return [HVACMode.OFF]
|
||||
return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]]
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
def hvac_mode(self):
|
||||
"""Return the desired HVAC mode."""
|
||||
if self._feature.is_on is None:
|
||||
return None
|
||||
@@ -76,7 +75,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
def hvac_action(self):
|
||||
"""Return the actual current HVAC action."""
|
||||
if self._feature.hvac_action is not None:
|
||||
if not self._feature.is_on:
|
||||
@@ -89,22 +88,22 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
return HVACAction.HEATING if self._feature.is_heating else HVACAction.IDLE
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature supported."""
|
||||
return self._feature.max_temp
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
def min_temp(self):
|
||||
"""Return the maximum temperature supported."""
|
||||
return self._feature.min_temp
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._feature.current
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
def target_temperature(self):
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
def color_mode(self):
|
||||
"""Return the color mode.
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
def supported_color_modes(self):
|
||||
"""Return supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
@@ -107,7 +107,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return self._feature.effect
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
def rgb_color(self):
|
||||
"""Return value for rgb."""
|
||||
if (rgb_hex := self._feature.rgb_hex) is None:
|
||||
return None
|
||||
@@ -118,14 +118,14 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def rgbw_color(self) -> tuple[int, int, int, int] | None:
|
||||
def rgbw_color(self):
|
||||
"""Return the hue and saturation."""
|
||||
if (rgbw_hex := self._feature.rgbw_hex) is None:
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4])
|
||||
|
||||
@property
|
||||
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
|
||||
def rgbww_color(self):
|
||||
"""Return value for rgbww."""
|
||||
if (rgbww_hex := self._feature.rgbww_hex) is None:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Sign in with Blink account",
|
||||
"title": "Sign-in with Blink account",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
@@ -30,7 +30,7 @@
|
||||
"step": {
|
||||
"simple_options": {
|
||||
"data": {
|
||||
"scan_interval": "Scan interval (seconds)"
|
||||
"scan_interval": "Scan Interval (seconds)"
|
||||
},
|
||||
"title": "Blink options",
|
||||
"description": "Configure Blink integration"
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"config_entry_id": {
|
||||
"name": "Integration ID",
|
||||
"description": "The Blink integration ID."
|
||||
"description": "The Blink Integration ID."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE
|
||||
|
||||
type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Entity representing a Blue Current charge point."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -15,12 +17,12 @@ class BlueCurrentEntity(Entity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
has_value = False
|
||||
|
||||
def __init__(self, connector: Connector, signal: str) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.connector = connector
|
||||
self.signal = signal
|
||||
self.has_value = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -41,6 +43,7 @@ class BlueCurrentEntity(Entity):
|
||||
return self.connector.connected and self.has_value
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
|
||||
|
||||
@@ -19,17 +19,6 @@
|
||||
"current_left": {
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"reset": {
|
||||
"default": "mdi:restart"
|
||||
},
|
||||
"reboot": {
|
||||
"default": "mdi:restart-alert"
|
||||
},
|
||||
"stop_charge_session": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blue_current",
|
||||
"name": "Blue Current",
|
||||
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
|
||||
"codeowners": ["@Floris272", "@gleeuwen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blue_current",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -113,17 +113,6 @@
|
||||
"grid_max_current": {
|
||||
"name": "Max grid current"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"stop_charge_session": {
|
||||
"name": "Stop charge session"
|
||||
},
|
||||
"reboot": {
|
||||
"name": "Reboot"
|
||||
},
|
||||
"reset": {
|
||||
"name": "Reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluemaestro",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bluemaestro-ble==0.4.1"]
|
||||
"requirements": ["bluemaestro-ble==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ from .coordinator import (
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.MEDIA_PLAYER,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""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)
|
||||
@@ -22,11 +22,7 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -38,7 +34,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
||||
from .coordinator import BluesoundCoordinator
|
||||
@@ -492,36 +488,10 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
|
||||
async def async_increase_timer(self) -> int:
|
||||
"""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()
|
||||
|
||||
async def async_clear_timer(self) -> None:
|
||||
"""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
|
||||
while sleep > 0:
|
||||
sleep = await self._player.sleep_timer()
|
||||
|
||||
@@ -26,16 +26,6 @@
|
||||
"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": {
|
||||
"join": {
|
||||
"name": "Join",
|
||||
@@ -81,15 +71,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"set_sleep_timer": {
|
||||
"name": "Set sleep timer"
|
||||
},
|
||||
"clear_sleep_timer": {
|
||||
"name": "Clear sleep timer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
"bleak==0.22.3",
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-auto-recovery==1.4.5",
|
||||
"bluetooth-data-tools==1.28.1",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.49.0"
|
||||
"habluetooth==3.45.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -111,7 +111,7 @@ class BMWButton(BMWBaseEntity, ButtonEntity):
|
||||
await self.entity_description.remote_function(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -22,7 +22,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
|
||||
from .const import (
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
DOMAIN as BMW_DOMAIN,
|
||||
SCAN_INTERVALS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,7 +63,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
update_interval=timedelta(
|
||||
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
|
||||
),
|
||||
@@ -75,26 +81,26 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
# Clear refresh token and trigger reauth if previous update failed as well
|
||||
self._update_config_entry_refresh_token(None)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (MyBMWAPIError, RequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
@@ -71,7 +71,7 @@ class BMWLock(BMWBaseEntity, LockEntity):
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
@@ -95,7 +95,7 @@ class BMWLock(BMWBaseEntity, LockEntity):
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -92,7 +92,7 @@ class BMWNotificationService(BaseNotificationService):
|
||||
|
||||
except (vol.Invalid, TypeError, ValueError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="invalid_poi",
|
||||
translation_placeholders={
|
||||
"poi_exception": str(ex),
|
||||
@@ -107,7 +107,7 @@ class BMWNotificationService(BaseNotificationService):
|
||||
await vehicle.remote_services.trigger_send_poi(poi)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
@@ -110,7 +110,7 @@ class BMWNumber(BMWBaseEntity, NumberEntity):
|
||||
await self.entity_description.remote_service(self.vehicle, value)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
@@ -124,7 +124,7 @@ class BMWSelect(BMWBaseEntity, SelectEntity):
|
||||
await self.entity_description.remote_service(self.vehicle, option)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "Door lock state"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"name": "Condition-based services"
|
||||
"name": "Condition based services"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"name": "Check control messages"
|
||||
@@ -81,7 +81,7 @@
|
||||
"name": "Connection status"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"name": "Pre-entry climatization"
|
||||
"name": "Pre entry climatization"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
@@ -112,7 +112,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
|
||||
await self.entity_description.remote_service_on(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
@@ -124,7 +124,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity):
|
||||
await self.entity_description.remote_service_off(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=BMW_DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError, ClientTimeout
|
||||
from bond_async import Bond, BPUPSubscriptions, RequestorUUID, start_bpup
|
||||
from bond_async import Bond, BPUPSubscriptions, start_bpup
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -49,7 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool
|
||||
token=token,
|
||||
timeout=ClientTimeout(total=_API_TIMEOUT),
|
||||
session=async_get_clientsession(hass),
|
||||
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
|
||||
)
|
||||
hub = BondHub(bond, host)
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectionError, ClientResponseError
|
||||
from bond_async import Bond, RequestorUUID
|
||||
from bond_async import Bond
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
|
||||
@@ -34,12 +34,7 @@ TOKEN_SCHEMA = vol.Schema({})
|
||||
|
||||
async def async_get_token(hass: HomeAssistant, host: str) -> str | None:
|
||||
"""Try to fetch the token from the bond device."""
|
||||
bond = Bond(
|
||||
host,
|
||||
"",
|
||||
session=async_get_clientsession(hass),
|
||||
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
|
||||
)
|
||||
bond = Bond(host, "", session=async_get_clientsession(hass))
|
||||
response: dict[str, str] = {}
|
||||
with contextlib.suppress(ClientConnectionError):
|
||||
response = await bond.token()
|
||||
@@ -50,10 +45,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
bond = Bond(
|
||||
data[CONF_HOST],
|
||||
data[CONF_ACCESS_TOKEN],
|
||||
session=async_get_clientsession(hass),
|
||||
requestor_uuid=RequestorUUID.HOME_ASSISTANT,
|
||||
data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass)
|
||||
)
|
||||
try:
|
||||
hub = BondHub(bond, data[CONF_HOST])
|
||||
|
||||
@@ -6,31 +6,17 @@ from ssl import SSLError
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
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 homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
from .services import setup_services
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
|
||||
|
||||
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
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
|
||||
@@ -62,11 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
mac = entry.data.get(CONF_MAC)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
|
||||
@@ -12,8 +12,8 @@ from homeassistant.components.alarm_control_panel import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -34,9 +34,6 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||
"""An alarm control panel entity for a bosch alarm panel."""
|
||||
|
||||
@@ -50,7 +47,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||
|
||||
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
||||
"""Initialise a Bosch Alarm control panel entity."""
|
||||
super().__init__(panel, area_id, unique_id, True, False, True)
|
||||
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||
self._attr_unique_id = self._area_unique_id
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""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()
|
||||
@@ -6,30 +6,25 @@ import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any, Self
|
||||
from typing import Any
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_DHCP,
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MODEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -93,12 +88,6 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Init config flow."""
|
||||
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -107,12 +96,9 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
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:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, _) = await try_connect(user_input, 0)
|
||||
(model, serial) = await try_connect(user_input, 0)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
@@ -143,70 +129,6 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -250,7 +172,7 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
if self.source in (SOURCE_USER, SOURCE_DHCP):
|
||||
if self.source == SOURCE_USER:
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
@@ -262,7 +184,6 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_mismatch(reason="device_mismatch")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=self._data,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Constants for the Bosch Alarm integration."""
|
||||
|
||||
DOMAIN = "bosch_alarm"
|
||||
ATTR_HISTORY = "history"
|
||||
HISTORY_ATTR = "history"
|
||||
CONF_INSTALLER_CODE = "installer_code"
|
||||
CONF_USER_CODE = "user_code"
|
||||
ATTR_DATETIME = "datetime"
|
||||
SERVICE_SET_DATE_TIME = "set_date_time"
|
||||
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user