Merge branch 'dev' of https://github.com/hanwg/core into feature/telegram-integration-config-flow

# Conflicts:
#	homeassistant/components/telegram/notify.py
This commit is contained in:
hanwg
2025-05-10 19:00:17 +08:00
800 changed files with 27825 additions and 9216 deletions

View File

@@ -37,9 +37,9 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 12 CACHE_VERSION: 1
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.6" HA_SHORT_VERSION: "2025.6"
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']" ALL_PYTHON_VERSIONS: "['3.13']"
@@ -259,7 +259,7 @@ jobs:
with: with:
path: venv path: venv
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
@@ -276,7 +276,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies - name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true' if: steps.cache-precommit.outputs.cache-hit != 'true'
@@ -306,7 +306,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@@ -315,7 +315,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Run ruff-format - name: Run ruff-format
run: | run: |
@@ -346,7 +346,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@@ -355,7 +355,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Run ruff - name: Run ruff
run: | run: |
@@ -386,7 +386,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
@@ -395,7 +395,7 @@ jobs:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Register yamllint problem matcher - name: Register yamllint problem matcher
@@ -501,7 +501,7 @@ jobs:
with: with:
path: venv path: venv
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache - name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
@@ -509,10 +509,10 @@ jobs:
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-uv-key.outputs.key }} steps.generate-uv-key.outputs.key }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Install additional OS dependencies - name: Install additional OS dependencies
@@ -598,7 +598,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run hassfest - name: Run hassfest
run: | run: |
@@ -631,7 +631,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run gen_requirements_all.py - name: Run gen_requirements_all.py
run: | run: |
@@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@v4.6.0 uses: actions/dependency-review-action@v4.7.0
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@@ -688,7 +688,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Extract license data - name: Extract license data
run: | run: |
@@ -731,7 +731,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher - name: Register pylint problem matcher
run: | run: |
@@ -778,7 +778,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register pylint problem matcher - name: Register pylint problem matcher
run: | run: |
@@ -830,17 +830,17 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@v4.2.3 uses: actions/cache@v4.2.3
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
steps.generate-mypy-key.outputs.key }} steps.generate-mypy-key.outputs.key }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{ env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Register mypy problem matcher - name: Register mypy problem matcher
@@ -900,7 +900,7 @@ jobs:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: >- key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Run split_tests.py - name: Run split_tests.py
run: | run: |
@@ -959,7 +959,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@@ -1084,7 +1085,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@@ -1218,7 +1220,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |
@@ -1369,7 +1372,8 @@ jobs:
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Register Python problem matcher - name: Register Python problem matcher
run: | run: |

View File

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

View File

@@ -332,6 +332,7 @@ homeassistant.components.media_player.*
homeassistant.components.media_source.* homeassistant.components.media_source.*
homeassistant.components.met_eireann.* homeassistant.components.met_eireann.*
homeassistant.components.metoffice.* homeassistant.components.metoffice.*
homeassistant.components.miele.*
homeassistant.components.mikrotik.* homeassistant.components.mikrotik.*
homeassistant.components.min_max.* homeassistant.components.min_max.*
homeassistant.components.minecraft_server.* homeassistant.components.minecraft_server.*
@@ -433,7 +434,6 @@ homeassistant.components.roku.*
homeassistant.components.romy.* homeassistant.components.romy.*
homeassistant.components.rpi_power.* homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.* homeassistant.components.rss_feed_template.*
homeassistant.components.rtsp_to_webrtc.*
homeassistant.components.russound_rio.* homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.* homeassistant.components.ruuvitag_ble.*

24
CODEOWNERS generated
View File

@@ -46,8 +46,8 @@ build.json @home-assistant/supervisor
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
/tests/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray
/homeassistant/components/adax/ @danielhiversen /homeassistant/components/adax/ @danielhiversen @lazytarget
/tests/components/adax/ @danielhiversen /tests/components/adax/ @danielhiversen @lazytarget
/homeassistant/components/adguard/ @frenck /homeassistant/components/adguard/ @frenck
/tests/components/adguard/ @frenck /tests/components/adguard/ @frenck
/homeassistant/components/ads/ @mrpasztoradam /homeassistant/components/ads/ @mrpasztoradam
@@ -171,6 +171,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/avea/ @pattyland /homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @danielsjf /homeassistant/components/awair/ @ahayworth @danielsjf
/tests/components/awair/ @ahayworth @danielsjf /tests/components/awair/ @ahayworth @danielsjf
/homeassistant/components/aws_s3/ @tomasbedrich
/tests/components/aws_s3/ @tomasbedrich
/homeassistant/components/axis/ @Kane610 /homeassistant/components/axis/ @Kane610
/tests/components/axis/ @Kane610 /tests/components/axis/ @Kane610
/homeassistant/components/azure_data_explorer/ @kaareseras /homeassistant/components/azure_data_explorer/ @kaareseras
@@ -453,8 +455,8 @@ build.json @home-assistant/supervisor
/tests/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb /homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 @baqs /homeassistant/components/ezviz/ @RenierM26
/tests/components/ezviz/ @RenierM26 @baqs /tests/components/ezviz/ @RenierM26
/homeassistant/components/faa_delays/ @ntilley905 /homeassistant/components/faa_delays/ @ntilley905
/tests/components/faa_delays/ @ntilley905 /tests/components/faa_delays/ @ntilley905
/homeassistant/components/fan/ @home-assistant/core /homeassistant/components/fan/ @home-assistant/core
@@ -1109,8 +1111,8 @@ build.json @home-assistant/supervisor
/tests/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23
/homeassistant/components/openuv/ @bachya /homeassistant/components/openuv/ @bachya
/tests/components/openuv/ @bachya /tests/components/openuv/ @bachya
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/tests/components/openweathermap/ @fabaff @freekode @nzapponi /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/homeassistant/components/opnsense/ @mtreinish /homeassistant/components/opnsense/ @mtreinish
/tests/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish
/homeassistant/components/opower/ @tronikos /homeassistant/components/opower/ @tronikos
@@ -1305,8 +1307,6 @@ build.json @home-assistant/supervisor
/tests/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast
/homeassistant/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rss_feed_template/ @home-assistant/core
/tests/components/rss_feed_template/ @home-assistant/core /tests/components/rss_feed_template/ @home-assistant/core
/homeassistant/components/rtsp_to_webrtc/ @allenporter
/tests/components/rtsp_to_webrtc/ @allenporter
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby /homeassistant/components/russound_rio/ @noahhusby
@@ -1318,8 +1318,6 @@ build.json @home-assistant/supervisor
/tests/components/ruuvitag_ble/ @akx /tests/components/ruuvitag_ble/ @akx
/homeassistant/components/rympro/ @OnFreund @elad-bar @maorcc /homeassistant/components/rympro/ @OnFreund @elad-bar @maorcc
/tests/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 /homeassistant/components/sabnzbd/ @shaiu @jpbede
/tests/components/sabnzbd/ @shaiu @jpbede /tests/components/sabnzbd/ @shaiu @jpbede
/homeassistant/components/saj/ @fredericvl /homeassistant/components/saj/ @fredericvl
@@ -1678,8 +1676,8 @@ build.json @home-assistant/supervisor
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74 /tests/components/vodafone_station/ @paoloantinori @chemelli74
/homeassistant/components/voip/ @balloob @synesthesiam /homeassistant/components/voip/ @balloob @synesthesiam @jaminh
/tests/components/voip/ @balloob @synesthesiam /tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvooncall/ @molobrakos /homeassistant/components/volvooncall/ @molobrakos
@@ -1796,6 +1794,8 @@ build.json @home-assistant/supervisor
/tests/components/zeversolar/ @kvanzuijlen /tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zodiac/ @JulienTant /homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core /homeassistant/components/zone/ @home-assistant/core

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
"""The S3 integration.""" """The AWS S3 integration."""
from __future__ import annotations from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""Backup platform for the S3 integration.""" """Backup platform for the AWS S3 integration."""
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
import functools import functools

View File

@@ -1,8 +1,9 @@
"""Config flow for the S3 integration.""" """Config flow for the AWS S3 integration."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from urllib.parse import urlparse
from aiobotocore.session import AioSession from aiobotocore.session import AioSession
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
@@ -17,6 +18,7 @@ from homeassistant.helpers.selector import (
) )
from .const import ( from .const import (
AWS_DOMAIN,
CONF_ACCESS_KEY_ID, CONF_ACCESS_KEY_ID,
CONF_BUCKET, CONF_BUCKET,
CONF_ENDPOINT_URL, CONF_ENDPOINT_URL,
@@ -57,6 +59,12 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL], CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
} }
) )
if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith(
AWS_DOMAIN
):
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
else:
try: try:
session = AioSession() session = AioSession()
async with session.create_client( async with session.create_client(

View File

@@ -1,18 +1,19 @@
"""Constants for the S3 integration.""" """Constants for the AWS S3 integration."""
from collections.abc import Callable from collections.abc import Callable
from typing import Final from typing import Final
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "s3" DOMAIN: Final = "aws_s3"
CONF_ACCESS_KEY_ID = "access_key_id" CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key" CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url" CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket" CONF_BUCKET = "bucket"
DEFAULT_ENDPOINT_URL = "https://s3.eu-central-1.amazonaws.com/" AWS_DOMAIN = "amazonaws.com"
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners" f"{DOMAIN}.backup_agent_listeners"

View File

@@ -1,9 +1,9 @@
{ {
"domain": "s3", "domain": "aws_s3",
"name": "S3", "name": "AWS S3",
"codeowners": ["@tomasbedrich"], "codeowners": ["@tomasbedrich"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/s3", "documentation": "https://www.home-assistant.io/integrations/aws_s3",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiobotocore"], "loggers": ["aiobotocore"],

View File

@@ -9,19 +9,19 @@
"endpoint_url": "Endpoint URL" "endpoint_url": "Endpoint URL"
}, },
"data_description": { "data_description": {
"access_key_id": "Access key ID to connect to S3 API", "access_key_id": "Access key ID to connect to AWS S3 API",
"secret_access_key": "Secret access key to connect to S3 API", "secret_access_key": "Secret access key to connect to AWS S3 API",
"bucket": "Bucket must already exist and be writable by the provided credentials.", "bucket": "Bucket must already exist and be writable by the provided credentials.",
"endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs." "endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs."
}, },
"title": "Add S3 bucket" "title": "Add AWS S3 bucket"
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:component::s3::exceptions::cannot_connect::message%]", "cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
"invalid_bucket_name": "[%key:component::s3::exceptions::invalid_bucket_name::message%]", "invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
"invalid_credentials": "[%key:component::s3::exceptions::invalid_credentials::message%]", "invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
"invalid_endpoint_url": "Invalid endpoint URL" "invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

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

View File

@@ -202,7 +202,7 @@ class BackupConfig:
if agent_id not in self.data.agents: if agent_id not in self.data.agents:
old_agent_retention = None old_agent_retention = None
self.data.agents[agent_id] = AgentConfig( self.data.agents[agent_id] = AgentConfig(
protected=agent_config.get("protected", False), protected=agent_config.get("protected", True),
retention=new_agent_retention, retention=new_agent_retention,
) )
else: else:

View File

@@ -30,6 +30,7 @@ class BackupCoordinatorData:
"""Class to hold backup data.""" """Class to hold backup data."""
backup_manager_state: BackupManagerState backup_manager_state: BackupManagerState
last_attempted_automatic_backup: datetime | None
last_successful_automatic_backup: datetime | None last_successful_automatic_backup: datetime | None
next_scheduled_automatic_backup: datetime | None next_scheduled_automatic_backup: datetime | None
@@ -70,6 +71,7 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
"""Update backup manager data.""" """Update backup manager data."""
return BackupCoordinatorData( return BackupCoordinatorData(
self.backup_manager.state, self.backup_manager.state,
self.backup_manager.config.data.last_attempted_automatic_backup,
self.backup_manager.config.data.last_completed_automatic_backup, self.backup_manager.config.data.last_completed_automatic_backup,
self.backup_manager.config.data.schedule.next_automatic_backup, self.backup_manager.config.data.schedule.next_automatic_backup,
) )

View File

@@ -22,7 +22,7 @@ from . import util
from .agent import BackupAgent from .agent import BackupAgent
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BackupManager from .manager import BackupManager
from .models import BackupNotFound from .models import AgentBackup, BackupNotFound
@callback @callback
@@ -85,7 +85,15 @@ class DownloadBackupView(HomeAssistantView):
request, headers, backup_id, agent_id, agent, manager request, headers, backup_id, agent_id, agent, manager
) )
return await self._send_backup_with_password( return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager hass,
backup,
request,
headers,
backup_id,
agent_id,
password,
agent,
manager,
) )
except BackupNotFound: except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND) return Response(status=HTTPStatus.NOT_FOUND)
@@ -116,6 +124,7 @@ class DownloadBackupView(HomeAssistantView):
async def _send_backup_with_password( async def _send_backup_with_password(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
backup: AgentBackup,
request: Request, request: Request,
headers: dict[istr, str], headers: dict[istr, str],
backup_id: str, backup_id: str,
@@ -144,7 +153,8 @@ class DownloadBackupView(HomeAssistantView):
stream = util.AsyncIteratorWriter(hass) stream = util.AsyncIteratorWriter(hass)
worker = threading.Thread( worker = threading.Thread(
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] target=util.decrypt_backup,
args=[backup, reader, stream, password, on_done, 0, []],
) )
try: try:
worker.start() worker.start()

View File

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

View File

@@ -37,6 +37,9 @@
"next_scheduled_automatic_backup": { "next_scheduled_automatic_backup": {
"name": "Next scheduled automatic backup" "name": "Next scheduled automatic backup"
}, },
"last_attempted_automatic_backup": {
"name": "Last attempted automatic backup"
},
"last_successful_automatic_backup": { "last_successful_automatic_backup": {
"name": "Last successful automatic backup" "name": "Last successful automatic backup"
} }

View File

@@ -295,13 +295,26 @@ def validate_password_stream(
raise BackupEmpty 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( def decrypt_backup(
backup: AgentBackup,
input_stream: IO[bytes], input_stream: IO[bytes],
output_stream: IO[bytes], output_stream: IO[bytes],
password: str | None, password: str | None,
on_done: Callable[[Exception | None], None], on_done: Callable[[Exception | None], None],
minimum_size: int, minimum_size: int,
nonces: list[bytes], nonces: NonceGenerator,
) -> None: ) -> None:
"""Decrypt a backup.""" """Decrypt a backup."""
error: Exception | None = None error: Exception | None = None
@@ -315,10 +328,13 @@ def decrypt_backup(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar, ) as output_tar,
): ):
_decrypt_backup(input_tar, output_tar, password) _decrypt_backup(backup, input_tar, output_tar, password)
except (DecryptError, SecureTarError, tarfile.TarError) as err: except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err) LOGGER.warning("Error decrypting backup: %s", err)
error = err error = err
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
else: else:
# Pad the output stream to the requested minimum size # Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0) padding = max(minimum_size - output_stream.tell(), 0)
@@ -333,15 +349,18 @@ def decrypt_backup(
def _decrypt_backup( def _decrypt_backup(
backup: AgentBackup,
input_tar: tarfile.TarFile, input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile, output_tar: tarfile.TarFile,
password: str | None, password: str | None,
) -> None: ) -> None:
"""Decrypt a backup.""" """Decrypt a backup."""
expected_archives = _get_expected_archives(backup)
for obj in input_tar: for obj in input_tar:
# We compare with PurePath to avoid issues with different path separators, # We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json" # for example when backup.json is added as "./backup.json"
if PurePath(obj.name) == PurePath("backup.json"): object_path = PurePath(obj.name)
if object_path == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is decrypted # Rewrite the backup.json file to indicate that the backup is decrypted
if not (reader := input_tar.extractfile(obj)): if not (reader := input_tar.extractfile(obj)):
raise DecryptError raise DecryptError
@@ -352,7 +371,13 @@ def _decrypt_backup(
metadata_obj.size = len(updated_metadata_b) metadata_obj.size = len(updated_metadata_b)
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue continue
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): 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)
output_tar.addfile(obj, input_tar.extractfile(obj)) output_tar.addfile(obj, input_tar.extractfile(obj))
continue continue
istf = SecureTarFile( istf = SecureTarFile(
@@ -371,12 +396,13 @@ def _decrypt_backup(
def encrypt_backup( def encrypt_backup(
backup: AgentBackup,
input_stream: IO[bytes], input_stream: IO[bytes],
output_stream: IO[bytes], output_stream: IO[bytes],
password: str | None, password: str | None,
on_done: Callable[[Exception | None], None], on_done: Callable[[Exception | None], None],
minimum_size: int, minimum_size: int,
nonces: list[bytes], nonces: NonceGenerator,
) -> None: ) -> None:
"""Encrypt a backup.""" """Encrypt a backup."""
error: Exception | None = None error: Exception | None = None
@@ -390,10 +416,13 @@ def encrypt_backup(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar, ) as output_tar,
): ):
_encrypt_backup(input_tar, output_tar, password, nonces) _encrypt_backup(backup, input_tar, output_tar, password, nonces)
except (EncryptError, SecureTarError, tarfile.TarError) as err: except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err) LOGGER.warning("Error encrypting backup: %s", err)
error = err error = err
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
else: else:
# Pad the output stream to the requested minimum size # Pad the output stream to the requested minimum size
padding = max(minimum_size - output_stream.tell(), 0) padding = max(minimum_size - output_stream.tell(), 0)
@@ -408,17 +437,20 @@ def encrypt_backup(
def _encrypt_backup( def _encrypt_backup(
backup: AgentBackup,
input_tar: tarfile.TarFile, input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile, output_tar: tarfile.TarFile,
password: str | None, password: str | None,
nonces: list[bytes], nonces: NonceGenerator,
) -> None: ) -> None:
"""Encrypt a backup.""" """Encrypt a backup."""
inner_tar_idx = 0 inner_tar_idx = 0
expected_archives = _get_expected_archives(backup)
for obj in input_tar: for obj in input_tar:
# We compare with PurePath to avoid issues with different path separators, # We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json" # for example when backup.json is added as "./backup.json"
if PurePath(obj.name) == PurePath("backup.json"): object_path = PurePath(obj.name)
if object_path == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is encrypted # Rewrite the backup.json file to indicate that the backup is encrypted
if not (reader := input_tar.extractfile(obj)): if not (reader := input_tar.extractfile(obj)):
raise EncryptError raise EncryptError
@@ -429,16 +461,21 @@ def _encrypt_backup(
metadata_obj.size = len(updated_metadata_b) metadata_obj.size = len(updated_metadata_b)
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue continue
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): 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)
output_tar.addfile(obj, input_tar.extractfile(obj)) output_tar.addfile(obj, input_tar.extractfile(obj))
continue continue
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
istf = SecureTarFile( istf = SecureTarFile(
None, # Not used None, # Not used
gzip=False, gzip=False,
key=password_to_key(password) if password is not None else None, key=password_to_key(password) if password is not None else None,
mode="r", mode="r",
fileobj=input_tar.extractfile(obj), fileobj=input_tar.extractfile(obj),
nonce=nonces[inner_tar_idx], nonce=nonces.get(inner_tar_idx),
) )
inner_tar_idx += 1 inner_tar_idx += 1
with istf.encrypt(obj) as encrypted: with istf.encrypt(obj) as encrypted:
@@ -456,17 +493,33 @@ class _CipherWorkerStatus:
writer: AsyncIteratorWriter 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: class _CipherBackupStreamer:
"""Encrypt or decrypt a backup.""" """Encrypt or decrypt a backup."""
_cipher_func: Callable[ _cipher_func: Callable[
[ [
AgentBackup,
IO[bytes], IO[bytes],
IO[bytes], IO[bytes],
str | None, str | None,
Callable[[Exception | None], None], Callable[[Exception | None], None],
int, int,
list[bytes], NonceGenerator,
], ],
None, None,
] ]
@@ -484,7 +537,7 @@ class _CipherBackupStreamer:
self._hass = hass self._hass = hass
self._open_stream = open_stream self._open_stream = open_stream
self._password = password self._password = password
self._nonces: list[bytes] = [] self._nonces = NonceGenerator()
def size(self) -> int: def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup.""" """Return the maximum size of the decrypted or encrypted backup."""
@@ -508,7 +561,15 @@ class _CipherBackupStreamer:
writer = AsyncIteratorWriter(self._hass) writer = AsyncIteratorWriter(self._hass)
worker = threading.Thread( worker = threading.Thread(
target=self._cipher_func, target=self._cipher_func,
args=[reader, writer, self._password, on_done, self.size(), self._nonces], args=[
self._backup,
reader,
writer,
self._password,
on_done,
self.size(),
self._nonces,
],
) )
worker_status = _CipherWorkerStatus( worker_status = _CipherWorkerStatus(
done=asyncio.Event(), reader=reader, thread=worker, writer=writer done=asyncio.Event(), reader=reader, thread=worker, writer=writer
@@ -538,17 +599,6 @@ class DecryptedBackupStreamer(_CipherBackupStreamer):
class EncryptedBackupStreamer(_CipherBackupStreamer): class EncryptedBackupStreamer(_CipherBackupStreamer):
"""Encrypt a backup.""" """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) _cipher_func = staticmethod(encrypt_backup)
def backup(self) -> AgentBackup: def backup(self) -> AgentBackup:

View File

@@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Sign-in with Blink account", "title": "Sign in with Blink account",
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
@@ -30,7 +30,7 @@
"step": { "step": {
"simple_options": { "simple_options": {
"data": { "data": {
"scan_interval": "Scan Interval (seconds)" "scan_interval": "Scan interval (seconds)"
}, },
"title": "Blink options", "title": "Blink options",
"description": "Configure Blink integration" "description": "Configure Blink integration"
@@ -93,7 +93,7 @@
}, },
"config_entry_id": { "config_entry_id": {
"name": "Integration ID", "name": "Integration ID",
"description": "The Blink Integration ID." "description": "The Blink integration ID."
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,11 @@ from homeassistant.helpers import device_registry as dr
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.SENSOR,
Platform.SWITCH,
]
type BoschAlarmConfigEntry = ConfigEntry[Panel] type BoschAlarmConfigEntry = ConfigEntry[Panel]

View File

@@ -86,3 +86,57 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
self._area.ready_observer.detach(self.schedule_update_ha_state) self._area.ready_observer.detach(self.schedule_update_ha_state)
if self._observe_status: if self._observe_status:
self._area.status_observer.detach(self.schedule_update_ha_state) self._area.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmDoorEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._door_id = door_id
self._door = panel.doors[door_id]
self._door_unique_id = f"{unique_id}_door_{door_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._door_unique_id)},
name=self._door.name,
manufacturer="Bosch Security Systems",
via_device=(DOMAIN, unique_id),
)
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
await super().async_added_to_hass()
self._door.status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
await super().async_added_to_hass()
self._door.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmOutputEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
"""Set up a output related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._output_id = output_id
self._output = panel.outputs[output_id]
self._output_unique_id = f"{unique_id}_output_{output_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._output_unique_id)},
name=self._output.name,
manufacturer="Bosch Security Systems",
via_device=(DOMAIN, unique_id),
)
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
await super().async_added_to_hass()
self._output.status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
await super().async_added_to_hass()
self._output.status_observer.detach(self.schedule_update_ha_state)

View File

@@ -2,7 +2,27 @@
"entity": { "entity": {
"sensor": { "sensor": {
"faulting_points": { "faulting_points": {
"default": "mdi:alert-circle-outline" "default": "mdi:alert-circle"
}
},
"switch": {
"locked": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-open"
}
},
"secured": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-open"
}
},
"cycling": {
"default": "mdi:lock",
"state": {
"on": "mdi:lock-open"
}
} }
} }
} }

View File

@@ -54,9 +54,23 @@
}, },
"authentication_failed": { "authentication_failed": {
"message": "Incorrect credentials for panel." "message": "Incorrect credentials for panel."
},
"incorrect_door_state": {
"message": "Door cannot be manipulated while it is being cycled."
} }
}, },
"entity": { "entity": {
"switch": {
"secured": {
"name": "Secured"
},
"cycling": {
"name": "Cycling"
},
"locked": {
"name": "Locked"
}
},
"sensor": { "sensor": {
"faulting_points": { "faulting_points": {
"name": "Faulting points", "name": "Faulting points",

View File

@@ -0,0 +1,150 @@
"""Support for Bosch Alarm Panel outputs and doors as switches."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.panel import Door
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .const import DOMAIN
from .entity import BoschAlarmDoorEntity, BoschAlarmOutputEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmSwitchEntityDescription(SwitchEntityDescription):
"""Describes Bosch Alarm door entity."""
value_fn: Callable[[Door], bool]
on_fn: Callable[[Panel, int], Coroutine[Any, Any, None]]
off_fn: Callable[[Panel, int], Coroutine[Any, Any, None]]
DOOR_SWITCH_TYPES: list[BoschAlarmSwitchEntityDescription] = [
BoschAlarmSwitchEntityDescription(
key="locked",
translation_key="locked",
value_fn=lambda door: door.is_locked(),
on_fn=lambda panel, door_id: panel.door_relock(door_id),
off_fn=lambda panel, door_id: panel.door_unlock(door_id),
),
BoschAlarmSwitchEntityDescription(
key="secured",
translation_key="secured",
value_fn=lambda door: door.is_secured(),
on_fn=lambda panel, door_id: panel.door_secure(door_id),
off_fn=lambda panel, door_id: panel.door_unsecure(door_id),
),
BoschAlarmSwitchEntityDescription(
key="cycling",
translation_key="cycling",
value_fn=lambda door: door.is_cycling(),
on_fn=lambda panel, door_id: panel.door_cycle(door_id),
off_fn=lambda panel, door_id: panel.door_relock(door_id),
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities for outputs."""
panel = config_entry.runtime_data
entities: list[SwitchEntity] = [
PanelOutputEntity(
panel, output_id, config_entry.unique_id or config_entry.entry_id
)
for output_id in panel.outputs
]
entities.extend(
PanelDoorEntity(
panel,
door_id,
config_entry.unique_id or config_entry.entry_id,
entity_description,
)
for door_id in panel.doors
for entity_description in DOOR_SWITCH_TYPES
)
async_add_entities(entities)
PARALLEL_UPDATES = 0
class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity):
"""A switch entity for a door on a bosch alarm panel."""
entity_description: BoschAlarmSwitchEntityDescription
def __init__(
self,
panel: Panel,
door_id: int,
unique_id: str,
entity_description: BoschAlarmSwitchEntityDescription,
) -> None:
"""Set up a switch entity for a door on a bosch alarm panel."""
super().__init__(panel, door_id, unique_id)
self.entity_description = entity_description
self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}"
@property
def is_on(self) -> bool:
"""Return the value function."""
return self.entity_description.value_fn(self._door)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Run the on function."""
# If the door is currently cycling, we can't send it any other commands until it is done
if self._door.is_cycling():
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="incorrect_door_state"
)
await self.entity_description.on_fn(self.panel, self._door_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Run the off function."""
# If the door is currently cycling, we can't send it any other commands until it is done
if self._door.is_cycling():
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="incorrect_door_state"
)
await self.entity_description.off_fn(self.panel, self._door_id)
class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity):
"""An output entity for a bosch alarm panel."""
_attr_name = None
def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
"""Set up an output entity for a bosch alarm panel."""
super().__init__(panel, output_id, unique_id)
self._attr_unique_id = self._output_unique_id
@property
def is_on(self) -> bool:
"""Check if this entity is on."""
return self._output.is_active()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on this output."""
await self.panel.set_output_active(self._output_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off this output."""
await self.panel.set_output_inactive(self._output_id)

View File

@@ -10,7 +10,12 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import BringConfigEntry, BringDataUpdateCoordinator from .coordinator import (
BringActivityCoordinator,
BringConfigEntry,
BringCoordinators,
BringDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
@@ -26,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo
coordinator = BringDataUpdateCoordinator(hass, entry, bring) coordinator = BringDataUpdateCoordinator(hass, entry, bring)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator activity_coordinator = BringActivityCoordinator(hass, entry, coordinator)
await activity_coordinator.async_config_entry_first_refresh()
entry.runtime_data = BringCoordinators(coordinator, activity_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -30,7 +30,15 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] type BringConfigEntry = ConfigEntry[BringCoordinators]
@dataclass
class BringCoordinators:
"""Data class holding coordinators."""
data: BringDataUpdateCoordinator
activity: BringActivityCoordinator
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -39,17 +47,28 @@ class BringData(DataClassORJSONMixin):
lst: BringList lst: BringList
content: BringItemsResponse content: BringItemsResponse
@dataclass(frozen=True)
class BringActivityData(DataClassORJSONMixin):
"""Coordinator data class."""
activity: BringActivityResponse activity: BringActivityResponse
users: BringUsersResponse users: BringUsersResponse
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""A Bring Data Update Coordinator.""" """Bring base coordinator."""
config_entry: BringConfigEntry config_entry: BringConfigEntry
user_settings: BringUserSettingsResponse
lists: list[BringList] lists: list[BringList]
class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
"""A Bring Data Update Coordinator."""
user_settings: BringUserSettingsResponse
def __init__( def __init__(
self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring
) -> None: ) -> None:
@@ -90,16 +109,19 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
current_lists := {lst.listUuid for lst in self.lists} current_lists := {lst.listUuid for lst in self.lists}
): ):
self._purge_deleted_lists() self._purge_deleted_lists()
new_lists = current_lists - self.previous_lists
self.previous_lists = current_lists self.previous_lists = current_lists
list_dict: dict[str, BringData] = {} list_dict: dict[str, BringData] = {}
for lst in self.lists: for lst in self.lists:
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: if (
(ctx := set(self.async_contexts()))
and lst.listUuid not in ctx
and lst.listUuid not in new_lists
):
continue continue
try: try:
items = await self.bring.get_list(lst.listUuid) items = await self.bring.get_list(lst.listUuid)
activity = await self.bring.get_activity(lst.listUuid)
users = await self.bring.get_list_users(lst.listUuid)
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@@ -111,7 +133,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_parse_exception", translation_key="setup_parse_exception",
) from e ) from e
else: else:
list_dict[lst.listUuid] = BringData(lst, items, activity, users) list_dict[lst.listUuid] = BringData(lst, items)
return list_dict return list_dict
@@ -156,3 +178,60 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
device_reg.async_update_device( device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id device.id, remove_config_entry_id=self.config_entry.entry_id
) )
class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]):
"""A Bring Activity Data Update Coordinator."""
user_settings: BringUserSettingsResponse
def __init__(
self,
hass: HomeAssistant,
config_entry: BringConfigEntry,
coordinator: BringDataUpdateCoordinator,
) -> None:
"""Initialize the Bring Activity data coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=10),
)
self.coordinator = coordinator
self.lists = coordinator.lists
async def _async_update_data(self) -> dict[str, BringActivityData]:
"""Fetch activity data from bring."""
list_dict: dict[str, BringActivityData] = {}
for lst in self.lists:
if (
ctx := set(self.coordinator.async_contexts())
) and lst.listUuid not in ctx:
continue
try:
activity = await self.coordinator.bring.get_activity(lst.listUuid)
users = await self.coordinator.bring.get_list_users(lst.listUuid)
except BringAuthException as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.coordinator.bring.mail},
) from e
except BringRequestException as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except BringParseException as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
else:
list_dict[lst.listUuid] = BringActivityData(activity, users)
return list_dict

View File

@@ -20,9 +20,12 @@ async def async_get_config_entry_diagnostics(
return { return {
"data": { "data": {
k: async_redact_data(v.to_dict(), TO_REDACT) k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items()
for k, v in config_entry.runtime_data.data.items()
}, },
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], "activity": {
"user_settings": config_entry.runtime_data.user_settings.to_dict(), k: async_redact_data(v.to_dict(), TO_REDACT)
for k, v in config_entry.runtime_data.activity.data.items()
},
"lists": [lst.to_dict() for lst in config_entry.runtime_data.data.lists],
"user_settings": config_entry.runtime_data.data.user_settings.to_dict(),
} }

View File

@@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringBaseCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]):
"""Bring base entity.""" """Bring base entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringBaseCoordinator,
bring_list: BringList, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
@@ -34,5 +34,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
}, },
manufacturer="Bring! Labs AG", manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List", model="Bring! Grocery Shopping List",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}"
if bring_list in self.coordinator.lists
else None,
) )

View File

@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BringConfigEntry from . import BringConfigEntry
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringActivityCoordinator
from .entity import BringBaseEntity from .entity import BringBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -32,18 +32,18 @@ async def async_setup_entry(
"""Add event entities.""" """Add event entities."""
nonlocal lists_added nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: if new_lists := {lst.listUuid for lst in coordinator.data.lists} - lists_added:
async_add_entities( async_add_entities(
BringEventEntity( BringEventEntity(
coordinator, coordinator.activity,
bring_list, bring_list,
) )
for bring_list in coordinator.lists for bring_list in coordinator.data.lists
if bring_list.listUuid in new_lists if bring_list.listUuid in new_lists
) )
lists_added |= new_lists lists_added |= new_lists
coordinator.async_add_listener(add_entities) coordinator.activity.async_add_listener(add_entities)
add_entities() add_entities()
@@ -51,10 +51,11 @@ class BringEventEntity(BringBaseEntity, EventEntity):
"""An event entity.""" """An event entity."""
_attr_translation_key = "activities" _attr_translation_key = "activities"
coordinator: BringActivityCoordinator
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringActivityCoordinator,
bring_list: BringList, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""

View File

@@ -88,7 +88,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the sensor platform.""" """Set up the sensor platform."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data.data
lists_added: set[str] = set() lists_added: set[str] = set()
@callback @callback
@@ -117,6 +117,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
"""A sensor entity.""" """A sensor entity."""
entity_description: BringSensorEntityDescription entity_description: BringSensorEntityDescription
coordinator: BringDataUpdateCoordinator
def __init__( def __init__(
self, self,

View File

@@ -44,7 +44,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the sensor from a config entry created in the integrations UI.""" """Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data.data
lists_added: set[str] = set() lists_added: set[str] = set()
@callback @callback
@@ -88,6 +88,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
| TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
) )
coordinator: BringDataUpdateCoordinator
def __init__( def __init__(
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList self, coordinator: BringDataUpdateCoordinator, bring_list: BringList

View File

@@ -11,6 +11,13 @@
}, },
"audio_output": { "audio_output": {
"default": "mdi:audio-input-stereo-minijack" "default": "mdi:audio-input-stereo-minijack"
},
"control_bus_mode": {
"default": "mdi:audio-video-off",
"state": {
"amplifier": "mdi:speaker",
"receiver": "mdi:audio-video"
}
} }
}, },
"switch": { "switch": {

View File

@@ -11,6 +11,7 @@ from aiostreammagic import (
StreamMagicClient, StreamMagicClient,
TransportControl, TransportControl,
) )
from aiostreammagic.models import ControlBusMode
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia, BrowseMedia,
@@ -91,6 +92,8 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
features = BASE_FEATURES features = BASE_FEATURES
if self.client.state.pre_amp_mode: if self.client.state.pre_amp_mode:
features |= PREAMP_FEATURES features |= PREAMP_FEATURES
if self.client.state.control_bus == ControlBusMode.AMPLIFIER:
features |= MediaPlayerEntityFeature.VOLUME_STEP
if TransportControl.PLAY_PAUSE in controls: if TransportControl.PLAY_PAUSE in controls:
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
for control in controls: for control in controls:

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from aiostreammagic import StreamMagicClient from aiostreammagic import StreamMagicClient
from aiostreammagic.models import DisplayBrightness from aiostreammagic.models import ControlBusMode, DisplayBrightness
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -76,6 +76,20 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
value_fn=_audio_output_value_fn, value_fn=_audio_output_value_fn,
set_value_fn=_audio_output_set_value_fn, set_value_fn=_audio_output_set_value_fn,
), ),
CambridgeAudioSelectEntityDescription(
key="control_bus_mode",
translation_key="control_bus_mode",
options=[
ControlBusMode.AMPLIFIER.value,
ControlBusMode.RECEIVER.value,
ControlBusMode.OFF.value,
],
entity_category=EntityCategory.CONFIG,
value_fn=lambda client: client.state.control_bus,
set_value_fn=lambda client, value: client.set_control_bus_mode(
ControlBusMode(value)
),
),
) )

View File

@@ -46,6 +46,14 @@
}, },
"audio_output": { "audio_output": {
"name": "Audio output" "name": "Audio output"
},
"control_bus_mode": {
"name": "Control Bus mode",
"state": {
"amplifier": "Amplifier",
"receiver": "Receiver",
"off": "[%key:common::state::off%]"
}
} }
}, },
"switch": { "switch": {

View File

@@ -61,7 +61,6 @@ from homeassistant.helpers.deprecation import (
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.network import get_url from homeassistant.helpers.network import get_url
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.helpers.typing import ConfigType, VolDictType
@@ -86,7 +85,6 @@ from .img_util import scale_jpeg_camera_image
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
from .webrtc import ( from .webrtc import (
DATA_ICE_SERVERS, DATA_ICE_SERVERS,
CameraWebRTCLegacyProvider,
CameraWebRTCProvider, CameraWebRTCProvider,
WebRTCAnswer, WebRTCAnswer,
WebRTCCandidate, # noqa: F401 WebRTCCandidate, # noqa: F401
@@ -94,10 +92,8 @@ from .webrtc import (
WebRTCError, WebRTCError,
WebRTCMessage, # noqa: F401 WebRTCMessage, # noqa: F401
WebRTCSendMessage, WebRTCSendMessage,
async_get_supported_legacy_provider,
async_get_supported_provider, async_get_supported_provider,
async_register_ice_servers, async_register_ice_servers,
async_register_rtsp_to_web_rtc_provider, # noqa: F401
async_register_webrtc_provider, # noqa: F401 async_register_webrtc_provider, # noqa: F401
async_register_ws, async_register_ws,
) )
@@ -436,7 +432,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
CACHED_PROPERTIES_WITH_ATTR_ = { CACHED_PROPERTIES_WITH_ATTR_ = {
"brand", "brand",
"frame_interval", "frame_interval",
"frontend_stream_type",
"is_on", "is_on",
"is_recording", "is_recording",
"is_streaming", "is_streaming",
@@ -456,8 +451,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Entity Properties # Entity Properties
_attr_brand: str | None = None _attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL _attr_frame_interval: float = MIN_STREAM_INTERVAL
# Deprecated in 2024.12. Remove in 2025.6
_attr_frontend_stream_type: StreamType | None
_attr_is_on: bool = True _attr_is_on: bool = True
_attr_is_recording: bool = False _attr_is_recording: bool = False
_attr_is_streaming: bool = False _attr_is_streaming: bool = False
@@ -480,7 +473,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self.async_update_token() self.async_update_token()
self._create_stream_lock: asyncio.Lock | None = None self._create_stream_lock: asyncio.Lock | None = None
self._webrtc_provider: CameraWebRTCProvider | None = None self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._supports_native_sync_webrtc = ( self._supports_native_sync_webrtc = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
) )
@@ -488,16 +480,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
type(self).async_handle_async_webrtc_offer type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer != Camera.async_handle_async_webrtc_offer
) )
self._deprecate_attr_frontend_stream_type_logged = False
if type(self).frontend_stream_type != Camera.frontend_stream_type:
report_usage(
(
f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class,"
" which is deprecated and will be removed in Home Assistant 2025.6, "
),
core_integration_behavior=ReportBehavior.ERROR,
exclude_integrations={DOMAIN},
)
@cached_property @cached_property
def entity_picture(self) -> str: def entity_picture(self) -> str:
@@ -559,40 +541,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the interval between frames of the mjpeg stream.""" """Return the interval between frames of the mjpeg stream."""
return self._attr_frame_interval return self._attr_frame_interval
@property
def frontend_stream_type(self) -> StreamType | None:
"""Return the type of stream supported by this camera.
A camera may have a single stream type which is used to inform the
frontend which camera attributes and player to use. The default type
is to use HLS, and components can override to change the type.
"""
# Deprecated in 2024.12. Remove in 2025.6
# Use the camera_capabilities instead
if hasattr(self, "_attr_frontend_stream_type"):
if not self._deprecate_attr_frontend_stream_type_logged:
report_usage(
(
f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class,"
" which is deprecated and will be removed in Home Assistant 2025.6, "
),
core_integration_behavior=ReportBehavior.ERROR,
exclude_integrations={DOMAIN},
)
self._deprecate_attr_frontend_stream_type_logged = True
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return None
if (
self._webrtc_provider
or self._legacy_webrtc_provider
or self._supports_native_sync_webrtc
or self._supports_native_async_webrtc
):
return StreamType.WEB_RTC
return StreamType.HLS
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
@@ -694,13 +642,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
) )
return return
if self._legacy_webrtc_provider and (
answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer(
self, offer_sdp
)
):
send_message(WebRTCAnswer(answer))
else:
raise HomeAssistantError("Camera does not support WebRTC") raise HomeAssistantError("Camera does not support WebRTC")
def camera_image( def camera_image(
@@ -797,9 +738,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if motion_detection_enabled := self.motion_detection_enabled: if motion_detection_enabled := self.motion_detection_enabled:
attrs["motion_detection"] = motion_detection_enabled attrs["motion_detection"] = motion_detection_enabled
if frontend_stream_type := self.frontend_stream_type:
attrs["frontend_stream_type"] = frontend_stream_type
return attrs return attrs
@callback @callback
@@ -823,9 +761,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
providers or inputs to the state attributes change. providers or inputs to the state attributes change.
""" """
old_provider = self._webrtc_provider old_provider = self._webrtc_provider
old_legacy_provider = self._legacy_webrtc_provider
new_provider = None new_provider = None
new_legacy_provider = None
# Skip all providers if the camera has a native WebRTC implementation # Skip all providers if the camera has a native WebRTC implementation
if not ( if not (
@@ -836,15 +772,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
async_get_supported_provider async_get_supported_provider
) )
if new_provider is None: if old_provider != new_provider:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
)
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
self._webrtc_provider = new_provider self._webrtc_provider = new_provider
self._legacy_webrtc_provider = new_legacy_provider
self._invalidate_camera_capabilities_cache() self._invalidate_camera_capabilities_cache()
if write_state: if write_state:
self.async_write_ha_state() self.async_write_ha_state()
@@ -879,10 +808,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
] ]
config.configuration.ice_servers.extend(ice_servers) config.configuration.ice_servers.extend(ice_servers)
config.get_candidates_upfront = ( config.get_candidates_upfront = self._supports_native_sync_webrtc
self._supports_native_sync_webrtc
or self._legacy_webrtc_provider is not None
)
return config return config
@@ -918,7 +844,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
else: else:
frontend_stream_types.add(StreamType.HLS) frontend_stream_types.add(StreamType.HLS)
if self._webrtc_provider or self._legacy_webrtc_provider: if self._webrtc_provider:
frontend_stream_types.add(StreamType.WEB_RTC) frontend_stream_types.add(StreamType.WEB_RTC)
return CameraCapabilities(frontend_stream_types) return CameraCapabilities(frontend_stream_types)

View File

@@ -46,10 +46,6 @@
} }
} }
} }
},
"legacy_webrtc_provider": {
"title": "Detected use of legacy WebRTC provider registered by {legacy_integration}",
"description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant."
} }
}, },
"services": { "services": {

View File

@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial, wraps from functools import cache, partial, wraps
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any
from mashumaro import MissingField from mashumaro import MissingField
import voluptuous as vol import voluptuous as vol
@@ -22,8 +22,7 @@ from webrtc_models import (
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.deprecation import deprecated_function
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid from homeassistant.util.ulid import ulid
@@ -39,9 +38,6 @@ _LOGGER = logging.getLogger(__name__)
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey( DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
"camera_webrtc_providers" "camera_webrtc_providers"
) )
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey(
"camera_webrtc_legacy_providers"
)
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey( DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
"camera_webrtc_ice_servers" "camera_webrtc_ice_servers"
) )
@@ -163,18 +159,6 @@ class CameraWebRTCProvider(ABC):
return ## This is an optional method so we need a default here. return ## This is an optional method so we need a default here.
class CameraWebRTCLegacyProvider(Protocol):
"""WebRTC provider."""
async def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
@callback @callback
def async_register_webrtc_provider( def async_register_webrtc_provider(
hass: HomeAssistant, hass: HomeAssistant,
@@ -204,8 +188,6 @@ def async_register_webrtc_provider(
async def _async_refresh_providers(hass: HomeAssistant) -> None: async def _async_refresh_providers(hass: HomeAssistant) -> None:
"""Check all cameras for any state changes for registered providers.""" """Check all cameras for any state changes for registered providers."""
_async_check_conflicting_legacy_provider(hass)
component = hass.data[DATA_COMPONENT] component = hass.data[DATA_COMPONENT]
await asyncio.gather( await asyncio.gather(
*(camera.async_refresh_providers() for camera in component.entities) *(camera.async_refresh_providers() for camera in component.entities)
@@ -380,21 +362,6 @@ async def async_get_supported_provider(
return None return None
async def async_get_supported_legacy_provider(
hass: HomeAssistant, camera: Camera
) -> CameraWebRTCLegacyProvider | None:
"""Return the first supported provider for the camera."""
providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)
if not providers or not (stream_source := await camera.stream_source()):
return None
for provider in providers.values():
if await provider.async_is_supported(stream_source):
return provider
return None
@callback @callback
def async_register_ice_servers( def async_register_ice_servers(
hass: HomeAssistant, hass: HomeAssistant,
@@ -411,94 +378,3 @@ def async_register_ice_servers(
servers.append(get_ice_server_fn) servers.append(get_ice_server_fn)
return remove return remove
# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future.
# Left it so custom integrations can still use it.
_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
# An RtspToWebRtcProvider accepts these inputs:
# stream_source: The RTSP url
# offer_sdp: The WebRTC SDP offer
# stream_id: A unique id for the stream, used to update an existing source
# The output is the SDP answer, or None if the source or offer is not eligible.
# The Callable may throw HomeAssistantError on failure.
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
def __init__(self, fn: RtspToWebRtcProviderType) -> None:
"""Initialize the RTSP to WebRTC provider."""
self._fn = fn
async def async_is_supported(self, stream_source: str) -> bool:
"""Return if this provider is supports the Camera as source."""
return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES)
async def async_handle_web_rtc_offer(
self, camera: Camera, offer_sdp: str
) -> str | None:
"""Handle the WebRTC offer and return an answer."""
if not (stream_source := await camera.stream_source()):
return None
return await self._fn(stream_source, offer_sdp, camera.entity_id)
@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6")
def async_register_rtsp_to_web_rtc_provider(
hass: HomeAssistant,
domain: str,
provider: RtspToWebRtcProviderType,
) -> Callable[[], None]:
"""Register an RTSP to WebRTC provider.
The first provider to satisfy the offer will be used.
"""
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")
legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {})
if domain in legacy_providers:
raise ValueError("Provider already registered")
provider_instance = _CameraRtspToWebRTCProvider(provider)
@callback
def remove_provider() -> None:
legacy_providers.pop(domain)
hass.async_create_task(_async_refresh_providers(hass))
legacy_providers[domain] = provider_instance
hass.async_create_task(_async_refresh_providers(hass))
return remove_provider
@callback
def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None:
"""Check if a legacy provider is registered together with the builtin provider."""
builtin_provider_domain = "go2rtc"
if (
(legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS))
and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS))
and any(provider.domain == builtin_provider_domain for provider in providers)
):
for domain in legacy_providers:
ir.async_create_issue(
hass,
DOMAIN,
f"legacy_webrtc_provider_{domain}",
is_fixable=False,
is_persistent=False,
issue_domain=domain,
learn_more_url="https://www.home-assistant.io/integrations/go2rtc/",
severity=ir.IssueSeverity.WARNING,
translation_key="legacy_webrtc_provider",
translation_placeholders={
"legacy_integration": domain,
"builtin_integration": builtin_provider_domain,
},
)

View File

@@ -10,12 +10,12 @@
"known_hosts": "Add known host" "known_hosts": "Add known host"
}, },
"data_description": { "data_description": {
"known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working" "known_hosts": "Hostnames or IP addresses of cast devices, use if mDNS discovery is not working"
} }
} }
}, },
"error": { "error": {
"invalid_known_hosts": "Known hosts must be a comma separated list of hosts." "invalid_known_hosts": "Known hosts must be a comma-separated list of hosts."
} }
}, },
"options": { "options": {

View File

@@ -61,7 +61,6 @@ from .const import (
CONF_RELAYER_SERVER, CONF_RELAYER_SERVER,
CONF_REMOTESTATE_SERVER, CONF_REMOTESTATE_SERVER,
CONF_SERVICEHANDLERS_SERVER, CONF_SERVICEHANDLERS_SERVER,
CONF_THINGTALK_SERVER,
CONF_USER_POOL_ID, CONF_USER_POOL_ID,
DATA_CLOUD, DATA_CLOUD,
DATA_CLOUD_LOG_HANDLER, DATA_CLOUD_LOG_HANDLER,
@@ -134,7 +133,6 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str,
vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str,
vol.Optional(CONF_THINGTALK_SERVER): str,
vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str,
} }
) )

View File

@@ -26,7 +26,11 @@ from homeassistant.core import Context, HassJob, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.util.aiohttp import MockRequest, serialize_response from homeassistant.util.aiohttp import MockRequest, serialize_response
from . import alexa_config, google_config from . import alexa_config, google_config
@@ -36,6 +40,7 @@ from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
VALID_REPAIR_TRANSLATION_KEYS = { VALID_REPAIR_TRANSLATION_KEYS = {
"no_subscription",
"warn_bad_custom_domain_configuration", "warn_bad_custom_domain_configuration",
"reset_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration",
} }
@@ -409,3 +414,7 @@ class CloudClient(Interface):
severity=IssueSeverity(severity), severity=IssueSeverity(severity),
is_fixable=False, is_fixable=False,
) )
async def async_delete_repair_issue(self, identifier: str) -> None:
"""Delete a repair issue."""
async_delete_issue(hass=self._hass, domain=DOMAIN, issue_id=identifier)

View File

@@ -81,7 +81,6 @@ CONF_ACME_SERVER = "acme_server"
CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server"
CONF_RELAYER_SERVER = "relayer_server" CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_THINGTALK_SERVER = "thingtalk_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
MODE_DEV = "development" MODE_DEV = "development"

View File

@@ -16,7 +16,7 @@ from typing import Any, Concatenate, cast
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
import attr import attr
from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk from hass_nabucasa import AlreadyConnectedError, Cloud, auth
from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice_data import TTS_VOICES from hass_nabucasa.voice_data import TTS_VOICES
import voluptuous as vol import voluptuous as vol
@@ -104,7 +104,6 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, alexa_list) websocket_api.async_register_command(hass, alexa_list)
websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, alexa_sync)
websocket_api.async_register_command(hass, thingtalk_convert)
websocket_api.async_register_command(hass, tts_info) websocket_api.async_register_command(hass, tts_info)
hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(GoogleActionsSyncView)
@@ -998,25 +997,6 @@ async def alexa_sync(
) )
@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str})
@websocket_api.async_response
async def thingtalk_convert(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Convert a query."""
cloud = hass.data[DATA_CLOUD]
async with asyncio.timeout(10):
try:
connection.send_result(
msg["id"], await thingtalk.async_convert(cloud, msg["query"])
)
except thingtalk.ThingTalkConversionError as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
@websocket_api.websocket_command({"type": "cloud/tts/info"}) @websocket_api.websocket_command({"type": "cloud/tts/info"})
def tts_info( def tts_info(
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.96.0"], "requirements": ["hass-nabucasa==0.100.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -62,6 +62,10 @@
} }
} }
}, },
"no_subscription": {
"title": "No subscription detected",
"description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}."
},
"warn_bad_custom_domain_configuration": { "warn_bad_custom_domain_configuration": {
"title": "Detected wrong custom domain configuration", "title": "Detected wrong custom domain configuration",
"description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME."

View File

@@ -65,8 +65,8 @@ rules:
status: todo status: todo
comment: missing implementation comment: missing implementation
entity-category: entity-category:
status: todo status: exempt
comment: PR in progress comment: no config or diagnostic entities
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done

View File

@@ -165,9 +165,7 @@ class ConfigManagerFlowIndexView(
"""Not implemented.""" """Not implemented."""
raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"])
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add")
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
@RequestDataValidator( @RequestDataValidator(
vol.Schema( vol.Schema(
{ {
@@ -218,16 +216,12 @@ class ConfigManagerFlowResourceView(
url = "/api/config/config_entries/flow/{flow_id}" url = "/api/config/config_entries/flow/{flow_id}"
name = "api:config:config_entries:flow:resource" name = "api:config:config_entries:flow:resource"
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add")
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
async def get(self, request: web.Request, /, flow_id: str) -> web.Response: async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
"""Get the current state of a data_entry_flow.""" """Get the current state of a data_entry_flow."""
return await super().get(request, flow_id) return await super().get(request, flow_id)
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add")
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
async def post(self, request: web.Request, flow_id: str) -> web.Response: async def post(self, request: web.Request, flow_id: str) -> web.Response:
"""Handle a POST request.""" """Handle a POST request."""
return await super().post(request, flow_id) return await super().post(request, flow_id)
@@ -262,9 +256,7 @@ class OptionManagerFlowIndexView(
url = "/api/config/config_entries/options/flow" url = "/api/config/config_entries/options/flow"
name = "api:config:config_entries:option:flow" name = "api:config:config_entries:option:flow"
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
"""Handle a POST request. """Handle a POST request.
@@ -281,16 +273,12 @@ class OptionManagerFlowResourceView(
url = "/api/config/config_entries/options/flow/{flow_id}" url = "/api/config/config_entries/options/flow/{flow_id}"
name = "api:config:config_entries:options:flow:resource" name = "api:config:config_entries:options:flow:resource"
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def get(self, request: web.Request, /, flow_id: str) -> web.Response: async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
"""Get the current state of a data_entry_flow.""" """Get the current state of a data_entry_flow."""
return await super().get(request, flow_id) return await super().get(request, flow_id)
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request: web.Request, flow_id: str) -> web.Response: async def post(self, request: web.Request, flow_id: str) -> web.Response:
"""Handle a POST request.""" """Handle a POST request."""
return await super().post(request, flow_id) return await super().post(request, flow_id)
@@ -304,9 +292,7 @@ class SubentryManagerFlowIndexView(
url = "/api/config/config_entries/subentries/flow" url = "/api/config/config_entries/subentries/flow"
name = "api:config:config_entries:subentries:flow" name = "api:config:config_entries:subentries:flow"
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
@RequestDataValidator( @RequestDataValidator(
vol.Schema( vol.Schema(
{ {
@@ -341,16 +327,12 @@ class SubentryManagerFlowResourceView(
url = "/api/config/config_entries/subentries/flow/{flow_id}" url = "/api/config/config_entries/subentries/flow/{flow_id}"
name = "api:config:config_entries:subentries:flow:resource" name = "api:config:config_entries:subentries:flow:resource"
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def get(self, request: web.Request, /, flow_id: str) -> web.Response: async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
"""Get the current state of a data_entry_flow.""" """Get the current state of a data_entry_flow."""
return await super().get(request, flow_id) return await super().get(request, flow_id)
@require_admin( @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request: web.Request, flow_id: str) -> web.Response: async def post(self, request: web.Request, flow_id: str) -> web.Response:
"""Handle a POST request.""" """Handle a POST request."""
return await super().post(request, flow_id) return await super().post(request, flow_id)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.4.30"] "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"]
} }

View File

@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as DANFOSS_AIR_DOMAIN from . import DOMAIN
def setup_platform( def setup_platform(
@@ -22,7 +22,7 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the available Danfoss Air sensors etc.""" """Set up the available Danfoss Air sensors etc."""
data = hass.data[DANFOSS_AIR_DOMAIN] data = hass.data[DOMAIN]
sensors = [ sensors = [
[ [

View File

@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as DANFOSS_AIR_DOMAIN from . import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the available Danfoss Air sensors etc.""" """Set up the available Danfoss Air sensors etc."""
data = hass.data[DANFOSS_AIR_DOMAIN] data = hass.data[DOMAIN]
sensors = [ sensors = [
[ [

View File

@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as DANFOSS_AIR_DOMAIN from . import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Danfoss Air HRV switch platform.""" """Set up the Danfoss Air HRV switch platform."""
data = hass.data[DANFOSS_AIR_DOMAIN] data = hass.data[DOMAIN]
switches = [ switches = [
[ [

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN as DECONZ_DOMAIN from .const import DOMAIN
from .hub import DeconzHub from .hub import DeconzHub
from .util import serial_from_unique_id from .util import serial_from_unique_id
@@ -59,12 +59,12 @@ class DeconzBase[_DeviceT: _DeviceType]:
return DeviceInfo( return DeviceInfo(
connections={(CONNECTION_ZIGBEE, self.serial)}, connections={(CONNECTION_ZIGBEE, self.serial)},
identifiers={(DECONZ_DOMAIN, self.serial)}, identifiers={(DOMAIN, self.serial)},
manufacturer=self._device.manufacturer, manufacturer=self._device.manufacturer,
model=self._device.model_id, model=self._device.model_id,
name=self._device.name, name=self._device.name,
sw_version=self._device.software_version, sw_version=self._device.software_version,
via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), via_device=(DOMAIN, self.hub.api.config.bridge_id),
) )
@@ -176,9 +176,9 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]):
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return a device description for device registry.""" """Return a device description for device registry."""
return DeviceInfo( return DeviceInfo(
identifiers={(DECONZ_DOMAIN, self._group_identifier)}, identifiers={(DOMAIN, self._group_identifier)},
manufacturer="Dresden Elektronik", manufacturer="Dresden Elektronik",
model="deCONZ group", model="deCONZ group",
name=self.group.name, name=self.group.name,
via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), via_device=(DOMAIN, self.hub.api.config.bridge_id),
) )

View File

@@ -38,7 +38,7 @@ from homeassistant.util.color import (
) )
from . import DeconzConfigEntry from . import DeconzConfigEntry
from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .const import DOMAIN, POWER_PLUGS
from .entity import DeconzDevice from .entity import DeconzDevice
from .hub import DeconzHub from .hub import DeconzHub
@@ -395,11 +395,11 @@ class DeconzGroup(DeconzBaseLight[Group]):
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return a device description for device registry.""" """Return a device description for device registry."""
return DeviceInfo( return DeviceInfo(
identifiers={(DECONZ_DOMAIN, self.unique_id)}, identifiers={(DOMAIN, self.unique_id)},
manufacturer="Dresden Elektronik", manufacturer="Dresden Elektronik",
model="deCONZ group", model="deCONZ group",
name=self._device.name, name=self._device.name,
via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), via_device=(DOMAIN, self.hub.api.config.bridge_id),
) )
@property @property

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/denonavr", "documentation": "https://www.home-assistant.io/integrations/denonavr",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["denonavr"], "loggers": ["denonavr"],
"requirements": ["denonavr==1.0.1"], "requirements": ["denonavr==1.1.0"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Denon", "manufacturer": "Denon",

View File

@@ -8,6 +8,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["devolo_home_control_api"], "loggers": ["devolo_home_control_api"],
"requirements": ["devolo-home-control-api==0.18.3"], "requirements": ["devolo-home-control-api==0.19.0"],
"zeroconf": ["_dvl-deviceapi._tcp.local."] "zeroconf": ["_dvl-deviceapi._tcp.local."]
} }

View File

@@ -15,7 +15,7 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==1.1.1", "aiodhcpwatcher==1.1.1",
"aiodiscover==2.6.1", "aiodiscover==2.7.0",
"cached-ipaddress==0.10.0" "cached-ipaddress==0.10.0"
] ]
} }

View File

@@ -68,7 +68,7 @@ async def async_validate_hostname(
result = False result = False
with contextlib.suppress(DNSError): with contextlib.suppress(DNSError):
result = bool( result = bool(
await aiodns.DNSResolver( await aiodns.DNSResolver( # type: ignore[call-overload]
nameservers=[resolver], udp_port=port, tcp_port=port nameservers=[resolver], udp_port=port, tcp_port=port
).query(hostname, qtype) ).query(hostname, qtype)
) )

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dnsip", "documentation": "https://www.home-assistant.io/integrations/dnsip",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["aiodns==3.2.0"] "requirements": ["aiodns==3.4.0"]
} }

View File

@@ -106,7 +106,7 @@ class WanIpSensor(SensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Get the current DNS IP address for hostname.""" """Get the current DNS IP address for hostname."""
try: try:
response = await self.resolver.query(self.hostname, self.querytype) response = await self.resolver.query(self.hostname, self.querytype) # type: ignore[call-overload]
except DNSError as err: except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err) _LOGGER.warning("Exception while resolving host: %s", err)
response = None response = None

View File

@@ -3,10 +3,10 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"events": "Comma separated list of events." "events": "Comma-separated list of events."
}, },
"data_description": { "data_description": {
"events": "Add a comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" "events": "Add a comma-separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion"
} }
} }
} }

View File

@@ -8,7 +8,7 @@ from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as DOVADO_DOMAIN from . import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -19,7 +19,7 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> DovadoSMSNotificationService: ) -> DovadoSMSNotificationService:
"""Get the Dovado Router SMS notification service.""" """Get the Dovado Router SMS notification service."""
return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client) return DovadoSMSNotificationService(hass.data[DOMAIN].client)
class DovadoSMSNotificationService(BaseNotificationService): class DovadoSMSNotificationService(BaseNotificationService):

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN as DOVADO_DOMAIN from . import DOMAIN
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
@@ -90,7 +90,7 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Dovado sensor platform.""" """Set up the Dovado sensor platform."""
dovado = hass.data[DOVADO_DOMAIN] dovado = hass.data[DOMAIN]
sensors = config[CONF_SENSORS] sensors = config[CONF_SENSORS]
entities = [ entities = [

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==13.0.1"] "requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"]
} }

View File

@@ -3,12 +3,12 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"phone_number": "Phone Number" "phone_number": "Phone number"
} }
}, },
"one_time_password": { "one_time_password": {
"data": { "data": {
"one_time_password": "One Time Password" "one_time_password": "One-time password"
} }
} }
}, },

View File

@@ -7,12 +7,12 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"advertise_ip": "Advertise IP Address", "advertise_ip": "Advertise IP address",
"advertise_port": "Advertise Port", "advertise_port": "Advertise port",
"host_ip": "Host IP Address", "host_ip": "Host IP address",
"listen_port": "Listen Port", "listen_port": "Listen port",
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"upnp_bind_multicast": "Bind multicast (True/False)" "upnp_bind_multicast": "Bind multicast"
}, },
"title": "Define server configuration" "title": "Define server configuration"
} }

View File

@@ -52,6 +52,7 @@ VALID_ENERGY_UNITS_GAS = {
UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET,
UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
*VALID_ENERGY_UNITS, *VALID_ENERGY_UNITS,
} }
VALID_VOLUME_UNITS_WATER: set[str] = { VALID_VOLUME_UNITS_WATER: set[str] = {

View File

@@ -50,6 +50,7 @@ GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET,
UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET,
UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_METERS,
UnitOfVolume.LITERS,
), ),
} }
GAS_PRICE_UNITS = tuple( GAS_PRICE_UNITS = tuple(

View File

@@ -22,5 +22,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.14.0"] "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"]
} }

View File

@@ -22,6 +22,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH, SOURCE_REAUTH,
SOURCE_RECONFIGURE, SOURCE_RECONFIGURE,
ConfigEntry, ConfigEntry,
@@ -31,6 +32,7 @@ from homeassistant.config_entries import (
) )
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -302,7 +304,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
) )
): ):
return return
if entry.source == SOURCE_IGNORE:
# Don't call _fetch_device_info() for ignored entries
raise AbortFlow("already_configured")
configured_host: str | None = entry.data.get(CONF_HOST)
configured_port: int | None = entry.data.get(CONF_PORT) configured_port: int | None = entry.data.get(CONF_PORT)
if configured_host == host and configured_port == port:
# Don't probe to verify the mac is correct since
# the host and port matches.
raise AbortFlow("already_configured")
configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) configured_psk: str | None = entry.data.get(CONF_NOISE_PSK)
await self._fetch_device_info(host, port or configured_port, configured_psk) await self._fetch_device_info(host, port or configured_port, configured_psk)
updates: dict[str, Any] = {} updates: dict[str, Any] = {}

View File

@@ -134,6 +134,22 @@ def esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]](
return _wrapper return _wrapper
def async_esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]](
func: Callable[[_EntityT], Awaitable[_R | None]],
) -> Callable[[_EntityT], Coroutine[Any, Any, _R | None]]:
"""Wrap a state property of an esphome entity.
This checks if the state object in the entity is set
and returns None if it is not set.
"""
@functools.wraps(func)
async def _wrapper(self: _EntityT) -> _R | None:
return await func(self) if self._has_state else None
return _wrapper
def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]]( def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]](
func: Callable[[_EntityT], float | None], func: Callable[[_EntityT], float | None],
) -> Callable[[_EntityT], float | None]: ) -> Callable[[_EntityT], float | None]:

View File

@@ -8,6 +8,7 @@ from collections.abc import Callable, Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import partial from functools import partial
import logging import logging
from operator import delitem
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
from aioesphomeapi import ( from aioesphomeapi import (
@@ -183,18 +184,7 @@ class RuntimeEntryData:
"""Register to receive callbacks when static info changes for an EntityInfo type.""" """Register to receive callbacks when static info changes for an EntityInfo type."""
callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks = self.entity_info_callbacks.setdefault(entity_info_type, [])
callbacks.append(callback_) callbacks.append(callback_)
return partial( return partial(callbacks.remove, callback_)
self._async_unsubscribe_register_static_info, callbacks, callback_
)
@callback
def _async_unsubscribe_register_static_info(
self,
callbacks: list[Callable[[list[EntityInfo]], None]],
callback_: Callable[[list[EntityInfo]], None],
) -> None:
"""Unsubscribe to when static info is registered."""
callbacks.remove(callback_)
@callback @callback
def async_register_key_static_info_updated_callback( def async_register_key_static_info_updated_callback(
@@ -206,18 +196,7 @@ class RuntimeEntryData:
callback_key = (type(static_info), static_info.key) callback_key = (type(static_info), static_info.key)
callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, [])
callbacks.append(callback_) callbacks.append(callback_)
return partial( return partial(callbacks.remove, callback_)
self._async_unsubscribe_static_key_info_updated, callbacks, callback_
)
@callback
def _async_unsubscribe_static_key_info_updated(
self,
callbacks: list[Callable[[EntityInfo], None]],
callback_: Callable[[EntityInfo], None],
) -> None:
"""Unsubscribe to when static info is updated ."""
callbacks.remove(callback_)
@callback @callback
def async_set_assist_pipeline_state(self, state: bool) -> None: def async_set_assist_pipeline_state(self, state: bool) -> None:
@@ -232,14 +211,7 @@ class RuntimeEntryData:
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Subscribe to assist pipeline updates.""" """Subscribe to assist pipeline updates."""
self.assist_pipeline_update_callbacks.append(update_callback) self.assist_pipeline_update_callbacks.append(update_callback)
return partial(self._async_unsubscribe_assist_pipeline_update, update_callback) return partial(self.assist_pipeline_update_callbacks.remove, update_callback)
@callback
def _async_unsubscribe_assist_pipeline_update(
self, update_callback: CALLBACK_TYPE
) -> None:
"""Unsubscribe to assist pipeline updates."""
self.assist_pipeline_update_callbacks.remove(update_callback)
@callback @callback
def async_remove_entities( def async_remove_entities(
@@ -337,12 +309,7 @@ class RuntimeEntryData:
def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
"""Subscribe to state updates.""" """Subscribe to state updates."""
self.device_update_subscriptions.add(callback_) self.device_update_subscriptions.add(callback_)
return partial(self._async_unsubscribe_device_update, callback_) return partial(self.device_update_subscriptions.remove, callback_)
@callback
def _async_unsubscribe_device_update(self, callback_: CALLBACK_TYPE) -> None:
"""Unsubscribe to device updates."""
self.device_update_subscriptions.remove(callback_)
@callback @callback
def async_subscribe_static_info_updated( def async_subscribe_static_info_updated(
@@ -350,14 +317,7 @@ class RuntimeEntryData:
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Subscribe to static info updates.""" """Subscribe to static info updates."""
self.static_info_update_subscriptions.add(callback_) self.static_info_update_subscriptions.add(callback_)
return partial(self._async_unsubscribe_static_info_updated, callback_) return partial(self.static_info_update_subscriptions.remove, callback_)
@callback
def _async_unsubscribe_static_info_updated(
self, callback_: Callable[[list[EntityInfo]], None]
) -> None:
"""Unsubscribe to static info updates."""
self.static_info_update_subscriptions.remove(callback_)
@callback @callback
def async_subscribe_state_update( def async_subscribe_state_update(
@@ -369,14 +329,7 @@ class RuntimeEntryData:
"""Subscribe to state updates.""" """Subscribe to state updates."""
subscription_key = (state_type, state_key) subscription_key = (state_type, state_key)
self.state_subscriptions[subscription_key] = entity_callback self.state_subscriptions[subscription_key] = entity_callback
return partial(self._async_unsubscribe_state_update, subscription_key) return partial(delitem, self.state_subscriptions, subscription_key)
@callback
def _async_unsubscribe_state_update(
self, subscription_key: tuple[type[EntityState], int]
) -> None:
"""Unsubscribe to state updates."""
self.state_subscriptions.pop(subscription_key)
@callback @callback
def async_update_state(self, state: EntityState) -> None: def async_update_state(self, state: EntityState) -> None:
@@ -523,7 +476,7 @@ class RuntimeEntryData:
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register to receive callbacks when the Assist satellite's configuration is updated.""" """Register to receive callbacks when the Assist satellite's configuration is updated."""
self.assist_satellite_config_update_callbacks.append(callback_) self.assist_satellite_config_update_callbacks.append(callback_)
return lambda: self.assist_satellite_config_update_callbacks.remove(callback_) return partial(self.assist_satellite_config_update_callbacks.remove, callback_)
@callback @callback
def async_assist_satellite_config_updated( def async_assist_satellite_config_updated(
@@ -540,7 +493,7 @@ class RuntimeEntryData:
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register to receive callbacks when the Assist satellite's wake word is set.""" """Register to receive callbacks when the Assist satellite's wake word is set."""
self.assist_satellite_set_wake_word_callbacks.append(callback_) self.assist_satellite_set_wake_word_callbacks.append(callback_)
return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_) return partial(self.assist_satellite_set_wake_word_callbacks.remove, callback_)
@callback @callback
def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None:

View File

@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==30.1.0", "aioesphomeapi==30.2.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==2.14.0" "bleak-esphome==2.15.1"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]
} }

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