diff --git a/.core_files.yaml b/.core_files.yaml
index 08cabb71164429..3f92ed87a84f04 100644
--- a/.core_files.yaml
+++ b/.core_files.yaml
@@ -146,6 +146,7 @@ requirements: &requirements
- homeassistant/package_constraints.txt
- requirements*.txt
- pyproject.toml
+ - script/licenses.py
any:
- *base_platforms
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index d0edc631762f1b..7f3c0b0e66e27c 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: translations
path: translations.tar.gz
@@ -197,7 +197,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
- uses: home-assistant/builder@2024.03.5
+ uses: home-assistant/builder@2024.08.1
with:
args: |
$BUILD_ARGS \
@@ -263,7 +263,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
- uses: home-assistant/builder@2024.03.5
+ uses: home-assistant/builder@2024.08.1
with:
args: |
$BUILD_ARGS \
@@ -323,7 +323,7 @@ jobs:
uses: actions/checkout@v4.1.7
- name: Install Cosign
- uses: sigstore/cosign-installer@v3.5.0
+ uses: sigstore/cosign-installer@v3.6.0
with:
cosign-release: "v2.2.3"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 142839e77ff26b..0f0850ade1af36 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -31,12 +31,16 @@ on:
description: "Only run mypy"
default: false
type: boolean
+ audit-licenses-only:
+ description: "Only run audit licenses"
+ default: false
+ type: boolean
env:
- CACHE_VERSION: 9
+ CACHE_VERSION: 10
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 8
- HA_SHORT_VERSION: "2024.8"
+ HA_SHORT_VERSION: "2024.9"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12']"
# 10.3 is the oldest supported version
@@ -222,6 +226,7 @@ jobs:
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
needs:
- info
steps:
@@ -343,6 +348,7 @@ jobs:
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
+
lint-other:
name: Check other linters
runs-on: ubuntu-24.04
@@ -508,8 +514,7 @@ jobs:
uv pip install -U "pip>=21.3.1" setuptools wheel
uv pip install -r requirements.txt
python -m script.gen_requirements_all ci
- uv pip install -r requirements_all_pytest.txt
- uv pip install -r requirements_test.txt
+ uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
hassfest:
@@ -518,6 +523,7 @@ jobs:
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
needs:
- info
- base
@@ -556,6 +562,7 @@ jobs:
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
needs:
- info
- base
@@ -589,7 +596,10 @@ jobs:
- info
- base
if: |
- needs.info.outputs.requirements == 'true'
+ (github.event.inputs.pylint-only != 'true'
+ && github.event.inputs.mypy-only != 'true'
+ || github.event.inputs.audit-licenses-only == 'true')
+ && needs.info.outputs.requirements == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@v4.1.7
@@ -613,7 +623,7 @@ jobs:
. venv/bin/activate
pip-licenses --format=json --output-file=licenses.json
- name: Upload licenses
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: licenses
path: licenses.json
@@ -628,6 +638,7 @@ jobs:
timeout-minutes: 20
if: |
github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.pylint-only == 'true'
needs:
- info
@@ -672,7 +683,9 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
if: |
- (github.event.inputs.mypy-only != 'true' || github.event.inputs.pylint-only == 'true')
+ (github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
+ || github.event.inputs.pylint-only == 'true')
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
needs:
- info
@@ -717,6 +730,7 @@ jobs:
runs-on: ubuntu-24.04
if: |
github.event.inputs.pylint-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
|| github.event.inputs.mypy-only == 'true'
needs:
- info
@@ -781,6 +795,7 @@ jobs:
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -818,7 +833,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -831,6 +846,7 @@ jobs:
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -904,6 +920,7 @@ jobs:
cov_params+=(--cov-report=xml)
fi
+ echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \
@@ -917,14 +934,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -950,6 +967,7 @@ jobs:
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.mariadb_groups != '[]'
needs:
- info
@@ -1042,7 +1060,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1050,7 +1068,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1075,6 +1093,7 @@ jobs:
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.postgresql_groups != '[]'
needs:
- info
@@ -1168,7 +1187,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1176,7 +1195,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1219,6 +1238,7 @@ jobs:
&& github.event.inputs.lint-only != 'true'
&& github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
+ && github.event.inputs.audit-licenses-only != 'true'
&& needs.info.outputs.tests_glob
&& needs.info.outputs.test_full_suite == 'false'
needs:
@@ -1309,14 +1329,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 7fe545e469c276..45c2b31d772f95 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.1.7
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.25.15
+ uses: github/codeql-action/init@v3.26.2
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.25.15
+ uses: github/codeql-action/analyze@v3.26.2
with:
category: "/language:python"
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index b74406b9c821da..694208d30ac5a9 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -82,14 +82,14 @@ jobs:
) > .env_file
- name: Upload env_file
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: env_file
path: ./.env_file
overwrite: true
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -101,7 +101,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
- uses: actions/upload-artifact@v4.3.4
+ uses: actions/upload-artifact@v4.3.6
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -211,7 +211,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
- skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic
+ skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_old-cython.txt"
@@ -226,7 +226,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
- skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic
+ skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa"
@@ -240,7 +240,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
- skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic
+ skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab"
@@ -254,7 +254,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
- skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic
+ skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 22e10d420d4cf9..f057931e2a830d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.5.5
+ rev: v0.5.7
hooks:
- id: ruff
args:
@@ -12,7 +12,7 @@ repos:
hooks:
- id: codespell
args:
- - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue
+ - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
exclude_types: [csv, json, html]
diff --git a/.strict-typing b/.strict-typing
index a4f6d198d978f8..1eec42ad209cf1 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -95,8 +95,6 @@ homeassistant.components.aruba.*
homeassistant.components.arwn.*
homeassistant.components.aseko_pool_live.*
homeassistant.components.assist_pipeline.*
-homeassistant.components.asterisk_cdr.*
-homeassistant.components.asterisk_mbox.*
homeassistant.components.asuswrt.*
homeassistant.components.autarco.*
homeassistant.components.auth.*
@@ -168,6 +166,7 @@ homeassistant.components.ecowitt.*
homeassistant.components.efergy.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
+homeassistant.components.elevenlabs.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
diff --git a/CODEOWNERS b/CODEOWNERS
index bf93676f962b31..6593c02c8a514e 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -108,6 +108,8 @@ build.json @home-assistant/supervisor
/tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex
/tests/components/anthemav/ @hyralex
+/homeassistant/components/anthropic/ @Shulyaka
+/tests/components/anthropic/ @Shulyaka
/homeassistant/components/aosmith/ @bdr99
/tests/components/aosmith/ @bdr99
/homeassistant/components/apache_kafka/ @bachya
@@ -347,8 +349,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
-/homeassistant/components/dsmr/ @Robbie1221 @frenck
-/tests/components/dsmr/ @Robbie1221 @frenck
+/homeassistant/components/dsmr/ @Robbie1221
+/tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duotecno/ @cereal2nd
@@ -376,6 +378,8 @@ build.json @home-assistant/supervisor
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
+/homeassistant/components/elevenlabs/ @sorgfresser
+/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco
@@ -429,6 +433,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/evil_genius_labs/ @balloob
/tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb
+/tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 @baqs
/tests/components/ezviz/ @RenierM26 @baqs
/homeassistant/components/faa_delays/ @ntilley905
@@ -821,8 +826,6 @@ build.json @home-assistant/supervisor
/tests/components/logbook/ @home-assistant/core
/homeassistant/components/logger/ @home-assistant/core
/tests/components/logger/ @home-assistant/core
-/homeassistant/components/logi_circle/ @evanjd
-/tests/components/logi_circle/ @evanjd
/homeassistant/components/london_underground/ @jpbede
/tests/components/london_underground/ @jpbede
/homeassistant/components/lookin/ @ANMalko @bdraco
@@ -965,6 +968,8 @@ build.json @home-assistant/supervisor
/tests/components/nfandroidtv/ @tkdrob
/homeassistant/components/nibe_heatpump/ @elupus
/tests/components/nibe_heatpump/ @elupus
+/homeassistant/components/nice_go/ @IceBotYT
+/tests/components/nice_go/ @IceBotYT
/homeassistant/components/nightscout/ @marciogranzotto
/tests/components/nightscout/ @marciogranzotto
/homeassistant/components/nilu/ @hfurubotten
@@ -1051,8 +1056,8 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
-/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
-/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
+/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
+/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
diff --git a/homeassistant/auth/permissions/events.py b/homeassistant/auth/permissions/events.py
index 9f2fb45f9f04fd..cb0506769bfd8c 100644
--- a/homeassistant/auth/permissions/events.py
+++ b/homeassistant/auth/permissions/events.py
@@ -18,9 +18,12 @@
EVENT_THEMES_UPDATED,
)
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
+from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
+from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
+from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
from homeassistant.util.event_type import EventType
# These are events that do not contain any sensitive data
@@ -41,4 +44,7 @@
EVENT_SHOPPING_LIST_UPDATED,
EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED,
+ EVENT_LABEL_REGISTRY_UPDATED,
+ EVENT_CATEGORY_REGISTRY_UPDATED,
+ EVENT_FLOOR_REGISTRY_UPDATED,
}
diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py
index 5b8ba535b5a8a1..6ea0925574e7e2 100644
--- a/homeassistant/block_async_io.py
+++ b/homeassistant/block_async_io.py
@@ -8,6 +8,7 @@
from http.client import HTTPConnection
import importlib
import os
+from ssl import SSLContext
import sys
import threading
import time
@@ -143,6 +144,24 @@ class BlockingCall:
strict_core=False,
skip_for_tests=True,
),
+ BlockingCall(
+ original_func=SSLContext.load_default_certs,
+ object=SSLContext,
+ function="load_default_certs",
+ check_allowed=None,
+ strict=False,
+ strict_core=False,
+ skip_for_tests=True,
+ ),
+ BlockingCall(
+ original_func=SSLContext.load_verify_locations,
+ object=SSLContext,
+ function="load_verify_locations",
+ check_allowed=None,
+ strict=False,
+ strict_core=False,
+ skip_for_tests=True,
+ ),
)
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 43f4d451497ff4..742a293e4c456a 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -586,10 +586,10 @@ async def async_enable_logging(
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
- sys.excepthook = lambda *args: logging.getLogger(None).exception(
+ sys.excepthook = lambda *args: logging.getLogger().exception(
"Uncaught exception", exc_info=args
)
- threading.excepthook = lambda args: logging.getLogger(None).exception(
+ threading.excepthook = lambda args: logging.getLogger().exception(
"Uncaught thread exception",
exc_info=( # type: ignore[arg-type]
args.exc_type,
@@ -616,10 +616,9 @@ async def async_enable_logging(
_create_log_file, err_log_path, log_rotate_days
)
- err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
- logger = logging.getLogger("")
+ logger = logging.getLogger()
logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING)
diff --git a/homeassistant/brands/asterisk.json b/homeassistant/brands/asterisk.json
deleted file mode 100644
index 1df3e660afe27e..00000000000000
--- a/homeassistant/brands/asterisk.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "domain": "asterisk",
- "name": "Asterisk",
- "integrations": ["asterisk_cdr", "asterisk_mbox"]
-}
diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py
index 5c1c37df5d83d0..c1463cd9a08cb1 100644
--- a/homeassistant/components/acer_projector/switch.py
+++ b/homeassistant/components/acer_projector/switch.py
@@ -81,7 +81,7 @@ def __init__(
write_timeout: int,
) -> None:
"""Init of the Acer projector."""
- self.ser = serial.Serial(
+ self.serial = serial.Serial(
port=serial_port, timeout=timeout, write_timeout=write_timeout
)
self._serial_port = serial_port
@@ -99,16 +99,16 @@ def _write_read(self, msg: str) -> str:
# was disconnected during runtime.
# This way the projector can be reconnected and will still work
try:
- if not self.ser.is_open:
- self.ser.open()
- self.ser.write(msg.encode("utf-8"))
+ if not self.serial.is_open:
+ self.serial.open()
+ self.serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
- ret = self.ser.read_until(size=20).decode("utf-8")
+ ret = self.serial.read_until(size=20).decode("utf-8")
except serial.SerialException:
_LOGGER.error("Problem communicating with %s", self._serial_port)
- self.ser.close()
+ self.serial.close()
return ret
def _write_read_format(self, msg: str) -> str:
diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py
index 7041a757a4277c..f5742718b12c6e 100644
--- a/homeassistant/components/ads/__init__.py
+++ b/homeassistant/components/ads/__init__.py
@@ -136,7 +136,7 @@ def handle_write_data_by_name(call: ServiceCall) -> None:
# Tuple to hold data needed for notification
-NotificationItem = namedtuple(
+NotificationItem = namedtuple( # noqa: PYI024
"NotificationItem", "hnotify huser name plc_datatype callback"
)
diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json
index d2e5c5fdc5abf3..3696e16b437ca9 100644
--- a/homeassistant/components/aemet/manifest.json
+++ b/homeassistant/components/aemet/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
- "requirements": ["AEMET-OpenData==0.5.3"]
+ "requirements": ["AEMET-OpenData==0.5.4"]
}
diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py
index 4438bf72a1a5cf..933d0c6b40b2e6 100644
--- a/homeassistant/components/agent_dvr/camera.py
+++ b/homeassistant/components/agent_dvr/camera.py
@@ -59,7 +59,7 @@ async def async_setup_entry(
platform = async_get_current_platform()
for service, method in CAMERA_SERVICES.items():
- platform.async_register_entity_service(service, {}, method)
+ platform.async_register_entity_service(service, None, method)
class AgentCamera(MjpegCamera):
diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py
index 69f1e70c6af4eb..7ee8ac6a3c75fd 100644
--- a/homeassistant/components/airgradient/__init__.py
+++ b/homeassistant/components/airgradient/__init__.py
@@ -21,6 +21,7 @@
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
+ Platform.UPDATE,
]
diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json
index efb18ae575230a..fed4fafdc74b9d 100644
--- a/homeassistant/components/airgradient/manifest.json
+++ b/homeassistant/components/airgradient/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["airgradient==0.7.1"],
+ "requirements": ["airgradient==0.8.0"],
"zeroconf": ["_airgradient._tcp.local."]
}
diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py
new file mode 100644
index 00000000000000..95e64930ea611c
--- /dev/null
+++ b/homeassistant/components/airgradient/update.py
@@ -0,0 +1,55 @@
+"""Airgradient Update platform."""
+
+from datetime import timedelta
+from functools import cached_property
+
+from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import AirGradientConfigEntry, AirGradientMeasurementCoordinator
+from .entity import AirGradientEntity
+
+SCAN_INTERVAL = timedelta(hours=1)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: AirGradientConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Airgradient update platform."""
+
+ data = config_entry.runtime_data
+
+ async_add_entities([AirGradientUpdate(data.measurement)], True)
+
+
+class AirGradientUpdate(AirGradientEntity, UpdateEntity):
+ """Representation of Airgradient Update."""
+
+ _attr_device_class = UpdateDeviceClass.FIRMWARE
+ coordinator: AirGradientMeasurementCoordinator
+
+ def __init__(self, coordinator: AirGradientMeasurementCoordinator) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{coordinator.serial_number}-update"
+
+ @cached_property
+ def should_poll(self) -> bool:
+ """Return True because we need to poll the latest version."""
+ return True
+
+ @property
+ def installed_version(self) -> str:
+ """Return the installed version of the entity."""
+ return self.coordinator.data.firmware_version
+
+ async def async_update(self) -> None:
+ """Update the entity."""
+ self._attr_latest_version = (
+ await self.coordinator.client.get_latest_firmware_version(
+ self.coordinator.serial_number
+ )
+ )
diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py
index 4d0563ddce8ec2..60fdbf12ca1827 100644
--- a/homeassistant/components/airvisual/__init__.py
+++ b/homeassistant/components/airvisual/__init__.py
@@ -31,7 +31,6 @@
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import (
aiohttp_client,
- config_validation as cv,
device_registry as dr,
entity_registry as er,
)
@@ -62,8 +61,6 @@
DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
@callback
def async_get_cloud_api_update_interval(
diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py
index 24ee37bbcb416b..406fd72a6db3d9 100644
--- a/homeassistant/components/airzone/config_flow.py
+++ b/homeassistant/components/airzone/config_flow.py
@@ -114,7 +114,7 @@ async def async_step_dhcp(
)
try:
await airzone.get_version()
- except AirzoneError as err:
+ except (AirzoneError, TimeoutError) as err:
raise AbortFlow("cannot_connect") from err
return await self.async_step_discovered_connection()
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 0c32787d8aefde..31ff7423ad6d13 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==0.8.1"]
+ "requirements": ["aioairzone==0.8.2"]
}
diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py
index f22515155f1555..3d6f6b42901c29 100644
--- a/homeassistant/components/airzone_cloud/binary_sensor.py
+++ b/homeassistant/components/airzone_cloud/binary_sensor.py
@@ -161,6 +161,11 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity):
entity_description: AirzoneBinarySensorEntityDescription
+ @property
+ def available(self) -> bool:
+ """Return Airzone Cloud binary sensor availability."""
+ return super().available and self.is_on is not None
+
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json
index 362973ae83364d..b691770e93495b 100644
--- a/homeassistant/components/airzone_cloud/manifest.json
+++ b/homeassistant/components/airzone_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
- "requirements": ["aioairzone-cloud==0.6.1"]
+ "requirements": ["aioairzone-cloud==0.6.2"]
}
diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py
index a3a456edd03b29..9f0ee01aca212d 100644
--- a/homeassistant/components/airzone_cloud/sensor.py
+++ b/homeassistant/components/airzone_cloud/sensor.py
@@ -189,6 +189,11 @@ async def async_setup_entry(
class AirzoneSensor(AirzoneEntity, SensorEntity):
"""Define an Airzone Cloud sensor."""
+ @property
+ def available(self) -> bool:
+ """Return Airzone Cloud sensor availability."""
+ return super().available and self.native_value is not None
+
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py
index 1ffeb7c73ac0f2..c49e14f2c6f807 100644
--- a/homeassistant/components/alert/__init__.py
+++ b/homeassistant/components/alert/__init__.py
@@ -124,9 +124,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not entities:
return False
- component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
- component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
+ component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
await component.async_add_entities(entities)
@@ -162,16 +162,8 @@ def __init__(
self._data = data
self._message_template = message_template
- if self._message_template is not None:
- self._message_template.hass = hass
-
self._done_message_template = done_message_template
- if self._done_message_template is not None:
- self._done_message_template.hass = hass
-
self._title_template = title_template
- if self._title_template is not None:
- self._title_template.hass = hass
self._notifiers = notifiers
self._can_ack = can_ack
diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py
index eed700602cebaf..0d75ee04b7a876 100644
--- a/homeassistant/components/alexa/flash_briefings.py
+++ b/homeassistant/components/alexa/flash_briefings.py
@@ -52,7 +52,6 @@ def __init__(self, hass: HomeAssistant, flash_briefings: ConfigType) -> None:
"""Initialize Alexa view."""
super().__init__()
self.flash_briefings = flash_briefings
- template.attach(hass, self.flash_briefings)
@callback
def get(
diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py
index 53bf67021387a2..3571f436ff6b22 100644
--- a/homeassistant/components/alexa/handlers.py
+++ b/homeassistant/components/alexa/handlers.py
@@ -1206,7 +1206,7 @@ async def async_api_set_mode(
raise AlexaInvalidValueError(msg)
# Remote Activity
- if instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
+ elif instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}":
activity = mode.split(".")[1]
activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
if activity != PRESET_MODE_NA and activities and activity in activities:
diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json
index 84a4e152c1d43e..de59d28925f2f9 100644
--- a/homeassistant/components/alexa/manifest.json
+++ b/homeassistant/components/alexa/manifest.json
@@ -5,5 +5,6 @@
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/alexa",
+ "integration_type": "system",
"iot_class": "cloud_push"
}
diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py
index 57c1ba791bae89..d7bcfa5698ed3c 100644
--- a/homeassistant/components/alexa/smart_home.py
+++ b/homeassistant/components/alexa/smart_home.py
@@ -194,7 +194,7 @@ async def async_handle_message(
try:
if not enabled:
- raise AlexaBridgeUnreachableError(
+ raise AlexaBridgeUnreachableError( # noqa: TRY301
"Alexa API not enabled in Home Assistant configuration"
)
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
index d0b04e53e67ae2..469ad7e6e069e4 100644
--- a/homeassistant/components/ambient_station/__init__.py
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -17,7 +17,6 @@
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.entity_registry as er
@@ -25,7 +24,6 @@
from .const import (
ATTR_LAST_DATA,
CONF_APP_KEY,
- DOMAIN,
LOGGER,
TYPE_SOLARRADIATION,
TYPE_SOLARRADIATION_LX,
@@ -37,7 +35,6 @@
DEFAULT_SOCKET_MIN_RETRY = 15
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
type AmbientStationConfigEntry = ConfigEntry[AmbientStation]
diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py
index b9b2701eac6543..0bf02b604f1dbb 100644
--- a/homeassistant/components/amcrest/camera.py
+++ b/homeassistant/components/amcrest/camera.py
@@ -499,7 +499,7 @@ async def _async_change_setting(
await getattr(self, f"_async_set_{func}")(value)
new_value = await getattr(self, f"_async_get_{func}")()
if new_value != value:
- raise AmcrestCommandFailed
+ raise AmcrestCommandFailed # noqa: TRY301
except (AmcrestError, AmcrestCommandFailed) as error:
if tries == 1:
log_update_error(_LOGGER, action, self.name, description, error)
diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py
index db50d6d3e1aced..3772fe4642b75e 100644
--- a/homeassistant/components/android_ip_webcam/__init__.py
+++ b/homeassistant/components/android_ip_webcam/__init__.py
@@ -14,7 +14,6 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
from .coordinator import AndroidIPCamDataUpdateCoordinator
@@ -27,9 +26,6 @@
]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Android IP Webcam from a config entry."""
websession = async_get_clientsession(hass)
diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py
index 884b5f60f578bf..75cf6ead6c39b5 100644
--- a/homeassistant/components/androidtv/media_player.py
+++ b/homeassistant/components/androidtv/media_player.py
@@ -87,7 +87,7 @@ async def async_setup_entry(
"adb_command",
)
platform.async_register_entity_service(
- SERVICE_LEARN_SENDEVENT, {}, "learn_sendevent"
+ SERVICE_LEARN_SENDEVENT, None, "learn_sendevent"
)
platform.async_register_entity_service(
SERVICE_DOWNLOAD,
diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py
new file mode 100644
index 00000000000000..aa6cf509fa1efb
--- /dev/null
+++ b/homeassistant/components/anthropic/__init__.py
@@ -0,0 +1,46 @@
+"""The Anthropic integration."""
+
+from __future__ import annotations
+
+import anthropic
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN, LOGGER
+
+PLATFORMS = (Platform.CONVERSATION,)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
+ """Set up Anthropic from a config entry."""
+ client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
+ try:
+ await client.messages.create(
+ model="claude-3-haiku-20240307",
+ max_tokens=1,
+ messages=[{"role": "user", "content": "Hi"}],
+ timeout=10.0,
+ )
+ except anthropic.AuthenticationError as err:
+ LOGGER.error("Invalid API key: %s", err)
+ return False
+ except anthropic.AnthropicError as err:
+ raise ConfigEntryNotReady(err) from err
+
+ entry.runtime_data = client
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload Anthropic."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py
new file mode 100644
index 00000000000000..01e16ec5350136
--- /dev/null
+++ b/homeassistant/components/anthropic/config_flow.py
@@ -0,0 +1,210 @@
+"""Config flow for Anthropic integration."""
+
+from __future__ import annotations
+
+import logging
+from types import MappingProxyType
+from typing import Any
+
+import anthropic
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
+from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import llm
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ TemplateSelector,
+)
+
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_MAX_TOKENS,
+ CONF_PROMPT,
+ CONF_RECOMMENDED,
+ CONF_TEMPERATURE,
+ DOMAIN,
+ RECOMMENDED_CHAT_MODEL,
+ RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_TEMPERATURE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+
+RECOMMENDED_OPTIONS = {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
+ CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
+}
+
+
+async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
+ """Validate the user input allows us to connect.
+
+ Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+ """
+ client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
+ await client.messages.create(
+ model="claude-3-haiku-20240307",
+ max_tokens=1,
+ messages=[{"role": "user", "content": "Hi"}],
+ timeout=10.0,
+ )
+
+
+class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Anthropic."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors = {}
+
+ if user_input is not None:
+ try:
+ await validate_input(self.hass, user_input)
+ except anthropic.APITimeoutError:
+ errors["base"] = "timeout_connect"
+ except anthropic.APIConnectionError:
+ errors["base"] = "cannot_connect"
+ except anthropic.APIStatusError as e:
+ if isinstance(e.body, dict):
+ errors["base"] = e.body.get("error", {}).get("type", "unknown")
+ else:
+ errors["base"] = "unknown"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title="Claude",
+ data=user_input,
+ options=RECOMMENDED_OPTIONS,
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
+ )
+
+ @staticmethod
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> OptionsFlow:
+ """Create the options flow."""
+ return AnthropicOptionsFlow(config_entry)
+
+
+class AnthropicOptionsFlow(OptionsFlow):
+ """Anthropic config flow options handler."""
+
+ def __init__(self, config_entry: ConfigEntry) -> None:
+ """Initialize options flow."""
+ self.config_entry = config_entry
+ self.last_rendered_recommended = config_entry.options.get(
+ CONF_RECOMMENDED, False
+ )
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
+
+ if user_input is not None:
+ if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
+ if user_input[CONF_LLM_HASS_API] == "none":
+ user_input.pop(CONF_LLM_HASS_API)
+ return self.async_create_entry(title="", data=user_input)
+
+ # Re-render the options again, now with the recommended options shown/hidden
+ self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
+
+ options = {
+ CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
+ CONF_PROMPT: user_input[CONF_PROMPT],
+ CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
+ }
+
+ suggested_values = options.copy()
+ if not suggested_values.get(CONF_PROMPT):
+ suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT
+
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(anthropic_config_option_schema(self.hass, options)),
+ suggested_values,
+ )
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=schema,
+ )
+
+
+def anthropic_config_option_schema(
+ hass: HomeAssistant,
+ options: dict[str, Any] | MappingProxyType[str, Any],
+) -> dict:
+ """Return a schema for Anthropic completion options."""
+ hass_apis: list[SelectOptionDict] = [
+ SelectOptionDict(
+ label="No control",
+ value="none",
+ )
+ ]
+ hass_apis.extend(
+ SelectOptionDict(
+ label=api.name,
+ value=api.id,
+ )
+ for api in llm.async_get_apis(hass)
+ )
+
+ schema = {
+ vol.Optional(CONF_PROMPT): TemplateSelector(),
+ vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector(
+ SelectSelectorConfig(options=hass_apis)
+ ),
+ vol.Required(
+ CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
+ ): bool,
+ }
+
+ if options.get(CONF_RECOMMENDED):
+ return schema
+
+ schema.update(
+ {
+ vol.Optional(
+ CONF_CHAT_MODEL,
+ default=RECOMMENDED_CHAT_MODEL,
+ ): str,
+ vol.Optional(
+ CONF_MAX_TOKENS,
+ default=RECOMMENDED_MAX_TOKENS,
+ ): int,
+ vol.Optional(
+ CONF_TEMPERATURE,
+ default=RECOMMENDED_TEMPERATURE,
+ ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ }
+ )
+ return schema
diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py
new file mode 100644
index 00000000000000..4ccf2c88faa2f4
--- /dev/null
+++ b/homeassistant/components/anthropic/const.py
@@ -0,0 +1,15 @@
+"""Constants for the Anthropic integration."""
+
+import logging
+
+DOMAIN = "anthropic"
+LOGGER = logging.getLogger(__package__)
+
+CONF_RECOMMENDED = "recommended"
+CONF_PROMPT = "prompt"
+CONF_CHAT_MODEL = "chat_model"
+RECOMMENDED_CHAT_MODEL = "claude-3-5-sonnet-20240620"
+CONF_MAX_TOKENS = "max_tokens"
+RECOMMENDED_MAX_TOKENS = 1024
+CONF_TEMPERATURE = "temperature"
+RECOMMENDED_TEMPERATURE = 1.0
diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py
new file mode 100644
index 00000000000000..20e555e9592955
--- /dev/null
+++ b/homeassistant/components/anthropic/conversation.py
@@ -0,0 +1,316 @@
+"""Conversation support for Anthropic."""
+
+from collections.abc import Callable
+import json
+from typing import Any, Literal, cast
+
+import anthropic
+from anthropic._types import NOT_GIVEN
+from anthropic.types import (
+ Message,
+ MessageParam,
+ TextBlock,
+ TextBlockParam,
+ ToolParam,
+ ToolResultBlockParam,
+ ToolUseBlock,
+ ToolUseBlockParam,
+)
+import voluptuous as vol
+from voluptuous_openapi import convert
+
+from homeassistant.components import conversation
+from homeassistant.components.conversation import trace
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError, TemplateError
+from homeassistant.helpers import device_registry as dr, intent, llm, template
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util import ulid
+
+from . import AnthropicConfigEntry
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_MAX_TOKENS,
+ CONF_PROMPT,
+ CONF_TEMPERATURE,
+ DOMAIN,
+ LOGGER,
+ RECOMMENDED_CHAT_MODEL,
+ RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_TEMPERATURE,
+)
+
+# Max number of back and forth with the LLM to generate a response
+MAX_TOOL_ITERATIONS = 10
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: AnthropicConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up conversation entities."""
+ agent = AnthropicConversationEntity(config_entry)
+ async_add_entities([agent])
+
+
+def _format_tool(
+ tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
+) -> ToolParam:
+ """Format tool specification."""
+ return ToolParam(
+ name=tool.name,
+ description=tool.description or "",
+ input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
+ )
+
+
+def _message_convert(
+ message: Message,
+) -> MessageParam:
+ """Convert from class to TypedDict."""
+ param_content: list[TextBlockParam | ToolUseBlockParam] = []
+
+ for message_content in message.content:
+ if isinstance(message_content, TextBlock):
+ param_content.append(TextBlockParam(type="text", text=message_content.text))
+ elif isinstance(message_content, ToolUseBlock):
+ param_content.append(
+ ToolUseBlockParam(
+ type="tool_use",
+ id=message_content.id,
+ name=message_content.name,
+ input=message_content.input,
+ )
+ )
+
+ return MessageParam(role=message.role, content=param_content)
+
+
+class AnthropicConversationEntity(
+ conversation.ConversationEntity, conversation.AbstractConversationAgent
+):
+ """Anthropic conversation agent."""
+
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(self, entry: AnthropicConfigEntry) -> None:
+ """Initialize the agent."""
+ self.entry = entry
+ self.history: dict[str, list[MessageParam]] = {}
+ self._attr_unique_id = entry.entry_id
+ self._attr_device_info = dr.DeviceInfo(
+ identifiers={(DOMAIN, entry.entry_id)},
+ manufacturer="Anthropic",
+ model="Claude",
+ entry_type=dr.DeviceEntryType.SERVICE,
+ )
+ if self.entry.options.get(CONF_LLM_HASS_API):
+ self._attr_supported_features = (
+ conversation.ConversationEntityFeature.CONTROL
+ )
+
+ @property
+ def supported_languages(self) -> list[str] | Literal["*"]:
+ """Return a list of supported languages."""
+ return MATCH_ALL
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to Home Assistant."""
+ await super().async_added_to_hass()
+ self.entry.async_on_unload(
+ self.entry.add_update_listener(self._async_entry_update_listener)
+ )
+
+ async def async_process(
+ self, user_input: conversation.ConversationInput
+ ) -> conversation.ConversationResult:
+ """Process a sentence."""
+ options = self.entry.options
+ intent_response = intent.IntentResponse(language=user_input.language)
+ llm_api: llm.APIInstance | None = None
+ tools: list[ToolParam] | None = None
+ user_name: str | None = None
+ llm_context = llm.LLMContext(
+ platform=DOMAIN,
+ context=user_input.context,
+ user_prompt=user_input.text,
+ language=user_input.language,
+ assistant=conversation.DOMAIN,
+ device_id=user_input.device_id,
+ )
+
+ if options.get(CONF_LLM_HASS_API):
+ try:
+ llm_api = await llm.async_get_api(
+ self.hass,
+ options[CONF_LLM_HASS_API],
+ llm_context,
+ )
+ except HomeAssistantError as err:
+ LOGGER.error("Error getting LLM API: %s", err)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Error preparing LLM API: {err}",
+ )
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=user_input.conversation_id
+ )
+ tools = [
+ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools
+ ]
+
+ if user_input.conversation_id is None:
+ conversation_id = ulid.ulid_now()
+ messages = []
+
+ elif user_input.conversation_id in self.history:
+ conversation_id = user_input.conversation_id
+ messages = self.history[conversation_id]
+
+ else:
+ # Conversation IDs are ULIDs. We generate a new one if not provided.
+ # If an old OLID is passed in, we will generate a new one to indicate
+ # a new conversation was started. If the user picks their own, they
+ # want to track a conversation and we respect it.
+ try:
+ ulid.ulid_to_bytes(user_input.conversation_id)
+ conversation_id = ulid.ulid_now()
+ except ValueError:
+ conversation_id = user_input.conversation_id
+
+ messages = []
+
+ if (
+ user_input.context
+ and user_input.context.user_id
+ and (
+ user := await self.hass.auth.async_get_user(user_input.context.user_id)
+ )
+ ):
+ user_name = user.name
+
+ try:
+ prompt_parts = [
+ template.Template(
+ llm.BASE_PROMPT
+ + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
+ self.hass,
+ ).async_render(
+ {
+ "ha_name": self.hass.config.location_name,
+ "user_name": user_name,
+ "llm_context": llm_context,
+ },
+ parse_result=False,
+ )
+ ]
+
+ except TemplateError as err:
+ LOGGER.error("Error rendering prompt: %s", err)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Sorry, I had a problem with my template: {err}",
+ )
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ if llm_api:
+ prompt_parts.append(llm_api.api_prompt)
+
+ prompt = "\n".join(prompt_parts)
+
+ # Create a copy of the variable because we attach it to the trace
+ messages = [*messages, MessageParam(role="user", content=user_input.text)]
+
+ LOGGER.debug("Prompt: %s", messages)
+ LOGGER.debug("Tools: %s", tools)
+ trace.async_conversation_trace_append(
+ trace.ConversationTraceEventType.AGENT_DETAIL,
+ {"system": prompt, "messages": messages},
+ )
+
+ client = self.entry.runtime_data
+
+ # To prevent infinite loops, we limit the number of iterations
+ for _iteration in range(MAX_TOOL_ITERATIONS):
+ try:
+ response = await client.messages.create(
+ model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
+ messages=messages,
+ tools=tools or NOT_GIVEN,
+ max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
+ system=prompt,
+ temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
+ )
+ except anthropic.AnthropicError as err:
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ f"Sorry, I had a problem talking to Anthropic: {err}",
+ )
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ LOGGER.debug("Response %s", response)
+
+ messages.append(_message_convert(response))
+
+ if response.stop_reason != "tool_use" or not llm_api:
+ break
+
+ tool_results: list[ToolResultBlockParam] = []
+ for tool_call in response.content:
+ if isinstance(tool_call, TextBlock):
+ LOGGER.info(tool_call.text)
+
+ if not isinstance(tool_call, ToolUseBlock):
+ continue
+
+ tool_input = llm.ToolInput(
+ tool_name=tool_call.name,
+ tool_args=cast(dict[str, Any], tool_call.input),
+ )
+ LOGGER.debug(
+ "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
+ )
+
+ try:
+ tool_response = await llm_api.async_call_tool(tool_input)
+ except (HomeAssistantError, vol.Invalid) as e:
+ tool_response = {"error": type(e).__name__}
+ if str(e):
+ tool_response["error_text"] = str(e)
+
+ LOGGER.debug("Tool response: %s", tool_response)
+ tool_results.append(
+ ToolResultBlockParam(
+ type="tool_result",
+ tool_use_id=tool_call.id,
+ content=json.dumps(tool_response),
+ )
+ )
+
+ messages.append(MessageParam(role="user", content=tool_results))
+
+ self.history[conversation_id] = messages
+
+ for content in response.content:
+ if isinstance(content, TextBlock):
+ intent_response.async_set_speech(content.text)
+ break
+
+ return conversation.ConversationResult(
+ response=intent_response, conversation_id=conversation_id
+ )
+
+ async def _async_entry_update_listener(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Handle options update."""
+ # Reload as we update device info + entity name + supported features
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json
new file mode 100644
index 00000000000000..7d51c458e4d554
--- /dev/null
+++ b/homeassistant/components/anthropic/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "anthropic",
+ "name": "Anthropic Conversation",
+ "after_dependencies": ["assist_pipeline", "intent"],
+ "codeowners": ["@Shulyaka"],
+ "config_flow": true,
+ "dependencies": ["conversation"],
+ "documentation": "https://www.home-assistant.io/integrations/anthropic",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "requirements": ["anthropic==0.31.2"]
+}
diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json
new file mode 100644
index 00000000000000..9550a1a6672d14
--- /dev/null
+++ b/homeassistant/components/anthropic/strings.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "prompt": "Instructions",
+ "chat_model": "[%key:common::generic::model%]",
+ "max_tokens": "Maximum tokens to return in response",
+ "temperature": "Temperature",
+ "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
+ "recommended": "Recommended model settings"
+ },
+ "data_description": {
+ "prompt": "Instruct how the LLM should respond. This can be a template."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py
index 73ed721158d050..7293a42f7e7f5b 100644
--- a/homeassistant/components/apcupsd/__init__.py
+++ b/homeassistant/components/apcupsd/__init__.py
@@ -8,7 +8,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN
from .coordinator import APCUPSdCoordinator
@@ -17,8 +16,6 @@
PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Use config values to set up a function enabling status retrieval."""
diff --git a/homeassistant/components/apcupsd/const.py b/homeassistant/components/apcupsd/const.py
index e24a66fdca13ec..56bf229579dbb4 100644
--- a/homeassistant/components/apcupsd/const.py
+++ b/homeassistant/components/apcupsd/const.py
@@ -4,3 +4,6 @@
DOMAIN: Final = "apcupsd"
CONNECTION_TIMEOUT: int = 10
+
+# Field name of last self test retrieved from apcupsd.
+LASTSTEST: Final = "laststest"
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index 8d2c1ee2af13f4..ff72208e9ce4b1 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -13,6 +13,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
+ STATE_UNKNOWN,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -25,7 +26,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import DOMAIN, LASTSTEST
from .coordinator import APCUPSdCoordinator
PARALLEL_UPDATES = 0
@@ -156,8 +157,8 @@
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
- "laststest": SensorEntityDescription(
- key="laststest",
+ LASTSTEST: SensorEntityDescription(
+ key=LASTSTEST,
translation_key="last_self_test",
),
"lastxfer": SensorEntityDescription(
@@ -417,7 +418,12 @@ async def async_setup_entry(
available_resources: set[str] = {k.lower() for k, _ in coordinator.data.items()}
entities = []
- for resource in available_resources:
+
+ # "laststest" is a special sensor that only appears when the APC UPS daemon has done a
+ # periodical (or manual) self test since last daemon restart. It might not be available
+ # when we set up the integration, and we do not know if it would ever be available. Here we
+ # add it anyway and mark it as unknown initially.
+ for resource in available_resources | {LASTSTEST}:
if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue
@@ -473,6 +479,14 @@ def _handle_coordinator_update(self) -> None:
def _update_attrs(self) -> None:
"""Update sensor attributes based on coordinator data."""
key = self.entity_description.key.upper()
+ # For most sensors the key will always be available for each refresh. However, some sensors
+ # (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
+ # performed) and may disappear again after certain event. So we mark the state as "unknown"
+ # when it becomes unknown after such events.
+ if key not in self.coordinator.data:
+ self._attr_native_value = STATE_UNKNOWN
+ return
+
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index b794b60b33dbb3..ba71fb0def10b5 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -390,6 +390,27 @@ async def post(
)
context = self.context(request)
+ if not hass.services.has_service(domain, service):
+ raise HTTPBadRequest from ServiceNotFound(domain, service)
+
+ if response_requested := "return_response" in request.query:
+ if (
+ hass.services.supports_response(domain, service)
+ is ha.SupportsResponse.NONE
+ ):
+ return self.json_message(
+ "Service does not support responses. Remove return_response from request.",
+ HTTPStatus.BAD_REQUEST,
+ )
+ elif (
+ hass.services.supports_response(domain, service) is ha.SupportsResponse.ONLY
+ ):
+ return self.json_message(
+ "Service call requires responses but caller did not ask for responses. "
+ "Add ?return_response to query parameters.",
+ HTTPStatus.BAD_REQUEST,
+ )
+
changed_states: list[json_fragment] = []
@ha.callback
@@ -406,13 +427,14 @@ def _async_save_changed_entities(
try:
# shield the service call from cancellation on connection drop
- await shield(
+ response = await shield(
hass.services.async_call(
domain,
service,
data, # type: ignore[arg-type]
blocking=True,
context=context,
+ return_response=response_requested,
)
)
except (vol.Invalid, ServiceNotFound) as ex:
@@ -420,6 +442,11 @@ def _async_save_changed_entities(
finally:
cancel_listen()
+ if response_requested:
+ return self.json(
+ {"changed_states": changed_states, "service_response": response}
+ )
+
return self.json(changed_states)
diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json
index 1f7ac45372edd8..9a05382951649a 100644
--- a/homeassistant/components/apple_tv/manifest.json
+++ b/homeassistant/components/apple_tv/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
- "requirements": ["pyatv==0.14.3"],
+ "requirements": ["pyatv==0.15.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",
diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py
index 91650201a876e8..372ce52e04981f 100644
--- a/homeassistant/components/apsystems/__init__.py
+++ b/homeassistant/components/apsystems/__init__.py
@@ -13,7 +13,12 @@
from .const import DEFAULT_PORT
from .coordinator import ApSystemsDataCoordinator
-PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.NUMBER,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
@dataclass
diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py
new file mode 100644
index 00000000000000..528203dc2d96d1
--- /dev/null
+++ b/homeassistant/components/apsystems/binary_sensor.py
@@ -0,0 +1,102 @@
+"""The read-only binary sensors for APsystems local API integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from APsystemsEZ1 import ReturnAlarmInfo
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import ApSystemsConfigEntry, ApSystemsData
+from .coordinator import ApSystemsDataCoordinator
+from .entity import ApSystemsEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class ApsystemsLocalApiBinarySensorDescription(BinarySensorEntityDescription):
+ """Describes Apsystens Inverter binary sensor entity."""
+
+ is_on: Callable[[ReturnAlarmInfo], bool | None]
+
+
+BINARY_SENSORS: tuple[ApsystemsLocalApiBinarySensorDescription, ...] = (
+ ApsystemsLocalApiBinarySensorDescription(
+ key="off_grid_status",
+ translation_key="off_grid_status",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on=lambda c: bool(c.og),
+ ),
+ ApsystemsLocalApiBinarySensorDescription(
+ key="dc_1_short_circuit_error_status",
+ translation_key="dc_1_short_circuit_error_status",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on=lambda c: bool(c.isce1),
+ ),
+ ApsystemsLocalApiBinarySensorDescription(
+ key="dc_2_short_circuit_error_status",
+ translation_key="dc_2_short_circuit_error_status",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on=lambda c: bool(c.isce2),
+ ),
+ ApsystemsLocalApiBinarySensorDescription(
+ key="output_fault_status",
+ translation_key="output_fault_status",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on=lambda c: bool(c.oe),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ApSystemsConfigEntry,
+ add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the binary sensor platform."""
+ config = config_entry.runtime_data
+
+ add_entities(
+ ApSystemsBinarySensorWithDescription(
+ data=config,
+ entity_description=desc,
+ )
+ for desc in BINARY_SENSORS
+ )
+
+
+class ApSystemsBinarySensorWithDescription(
+ CoordinatorEntity[ApSystemsDataCoordinator], ApSystemsEntity, BinarySensorEntity
+):
+ """Base binary sensor to be used with description."""
+
+ entity_description: ApsystemsLocalApiBinarySensorDescription
+
+ def __init__(
+ self,
+ data: ApSystemsData,
+ entity_description: ApsystemsLocalApiBinarySensorDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(data.coordinator)
+ ApSystemsEntity.__init__(self, data)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{data.device_id}_{entity_description.key}"
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return value of sensor."""
+ return self.entity_description.is_on(self.coordinator.data.alarm_info)
diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py
index f2d076ce3fd322..96956bafc3ea43 100644
--- a/homeassistant/components/apsystems/coordinator.py
+++ b/homeassistant/components/apsystems/coordinator.py
@@ -2,9 +2,10 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import timedelta
-from APsystemsEZ1 import APsystemsEZ1M, ReturnOutputData
+from APsystemsEZ1 import APsystemsEZ1M, ReturnAlarmInfo, ReturnOutputData
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -12,7 +13,15 @@
from .const import LOGGER
-class ApSystemsDataCoordinator(DataUpdateCoordinator[ReturnOutputData]):
+@dataclass
+class ApSystemsSensorData:
+ """Representing different Apsystems sensor data."""
+
+ output_data: ReturnOutputData
+ alarm_info: ReturnAlarmInfo
+
+
+class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
"""Coordinator used for all sensors."""
def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None:
@@ -25,5 +34,7 @@ def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None:
)
self.api = api
- async def _async_update_data(self) -> ReturnOutputData:
- return await self.api.get_output_data()
+ async def _async_update_data(self) -> ApSystemsSensorData:
+ output_data = await self.api.get_output_data()
+ alarm_info = await self.api.get_alarm_info()
+ return ApSystemsSensorData(output_data=output_data, alarm_info=alarm_info)
diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py
index 637def4e41866b..afeb9d071ab1c8 100644
--- a/homeassistant/components/apsystems/sensor.py
+++ b/homeassistant/components/apsystems/sensor.py
@@ -148,4 +148,4 @@ def __init__(
@property
def native_value(self) -> StateType:
"""Return value of sensor."""
- return self.entity_description.value_fn(self.coordinator.data)
+ return self.entity_description.value_fn(self.coordinator.data.output_data)
diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json
index 18200f7b49d0dc..e02f86c273055c 100644
--- a/homeassistant/components/apsystems/strings.json
+++ b/homeassistant/components/apsystems/strings.json
@@ -19,6 +19,20 @@
}
},
"entity": {
+ "binary_sensor": {
+ "off_grid_status": {
+ "name": "Off grid status"
+ },
+ "dc_1_short_circuit_error_status": {
+ "name": "DC 1 short circuit error status"
+ },
+ "dc_2_short_circuit_error_status": {
+ "name": "DC 2 short circuit error status"
+ },
+ "output_fault_status": {
+ "name": "Output fault status"
+ }
+ },
"sensor": {
"total_power": {
"name": "Total power"
diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py
index e1a2ee0a046628..71639ed83888ac 100644
--- a/homeassistant/components/arcam_fmj/__init__.py
+++ b/homeassistant/components/arcam_fmj/__init__.py
@@ -11,12 +11,10 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
DEFAULT_SCAN_INTERVAL,
- DOMAIN,
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
@@ -26,7 +24,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [Platform.MEDIA_PLAYER]
diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py
index ab502fa275a988..8c68c13018b900 100644
--- a/homeassistant/components/arest/sensor.py
+++ b/homeassistant/components/arest/sensor.py
@@ -87,8 +87,6 @@ def make_renderer(value_template):
if value_template is None:
return lambda value: value
- value_template.hass = hass
-
def _render(value):
try:
return value_template.async_render({"value": value}, parse_result=False)
diff --git a/homeassistant/components/artsound/__init__.py b/homeassistant/components/artsound/__init__.py
new file mode 100644
index 00000000000000..149f06bc7c7cd9
--- /dev/null
+++ b/homeassistant/components/artsound/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: ArtSound."""
diff --git a/homeassistant/components/artsound/manifest.json b/homeassistant/components/artsound/manifest.json
new file mode 100644
index 00000000000000..589ba8621025d7
--- /dev/null
+++ b/homeassistant/components/artsound/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "artsound",
+ "name": "ArtSound",
+ "integration_type": "virtual",
+ "supported_by": "linkplay"
+}
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index f481411e55166a..8ee053162b0663 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -16,6 +16,10 @@
DATA_LAST_WAKE_UP,
DOMAIN,
EVENT_RECORDING,
+ SAMPLE_CHANNELS,
+ SAMPLE_RATE,
+ SAMPLE_WIDTH,
+ SAMPLES_PER_CHUNK,
)
from .error import PipelineNotFound
from .pipeline import (
@@ -53,6 +57,10 @@
"PipelineNotFound",
"WakeWordSettings",
"EVENT_RECORDING",
+ "SAMPLES_PER_CHUNK",
+ "SAMPLE_RATE",
+ "SAMPLE_WIDTH",
+ "SAMPLE_CHANNELS",
)
CONFIG_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/assist_pipeline/audio_enhancer.py b/homeassistant/components/assist_pipeline/audio_enhancer.py
new file mode 100644
index 00000000000000..c9c60f421b1b86
--- /dev/null
+++ b/homeassistant/components/assist_pipeline/audio_enhancer.py
@@ -0,0 +1,72 @@
+"""Audio enhancement for Assist."""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+import logging
+
+from pymicro_vad import MicroVad
+
+from .const import BYTES_PER_CHUNK
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class EnhancedAudioChunk:
+ """Enhanced audio chunk and metadata."""
+
+ audio: bytes
+ """Raw PCM audio @ 16Khz with 16-bit mono samples"""
+
+ timestamp_ms: int
+ """Timestamp relative to start of audio stream (milliseconds)"""
+
+ is_speech: bool | None
+ """True if audio chunk likely contains speech, False if not, None if unknown"""
+
+
+class AudioEnhancer(ABC):
+ """Base class for audio enhancement."""
+
+ def __init__(
+ self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
+ ) -> None:
+ """Initialize audio enhancer."""
+ self.auto_gain = auto_gain
+ self.noise_suppression = noise_suppression
+ self.is_vad_enabled = is_vad_enabled
+
+ @abstractmethod
+ def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
+ """Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
+
+
+class MicroVadEnhancer(AudioEnhancer):
+ """Audio enhancer that just runs microVAD."""
+
+ def __init__(
+ self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
+ ) -> None:
+ """Initialize audio enhancer."""
+ super().__init__(auto_gain, noise_suppression, is_vad_enabled)
+
+ self.vad: MicroVad | None = None
+ self.threshold = 0.5
+
+ if self.is_vad_enabled:
+ self.vad = MicroVad()
+ _LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold)
+
+ def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
+ """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
+ is_speech: bool | None = None
+
+ if self.vad is not None:
+ # Run VAD
+ assert len(audio) == BYTES_PER_CHUNK
+ speech_prob = self.vad.Process10ms(audio)
+ is_speech = speech_prob > self.threshold
+
+ return EnhancedAudioChunk(
+ audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech
+ )
diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py
index 36b72dad69c59e..f7306b89a54dbd 100644
--- a/homeassistant/components/assist_pipeline/const.py
+++ b/homeassistant/components/assist_pipeline/const.py
@@ -15,3 +15,10 @@
WAKE_WORD_COOLDOWN = 2 # seconds
EVENT_RECORDING = f"{DOMAIN}_recording"
+
+SAMPLE_RATE = 16000 # hertz
+SAMPLE_WIDTH = 2 # bytes
+SAMPLE_CHANNELS = 1 # mono
+MS_PER_CHUNK = 10
+SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
+BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json
index 31b3b0d4e32abf..00950b138fdb56 100644
--- a/homeassistant/components/assist_pipeline/manifest.json
+++ b/homeassistant/components/assist_pipeline/manifest.json
@@ -4,7 +4,8 @@
"codeowners": ["@balloob", "@synesthesiam"],
"dependencies": ["conversation", "stt", "tts", "wake_word"],
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
+ "integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["webrtc-noise-gain==1.2.3"]
+ "requirements": ["pymicro-vad==1.0.1"]
}
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index ecf361cb67cdb5..9fada934ca13bf 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -13,14 +13,11 @@
from queue import Empty, Queue
from threading import Thread
import time
-from typing import TYPE_CHECKING, Any, Final, Literal, cast
+from typing import Any, Literal, cast
import wave
import voluptuous as vol
-if TYPE_CHECKING:
- from webrtc_noise_gain import AudioProcessor
-
from homeassistant.components import (
conversation,
media_source,
@@ -52,12 +49,19 @@
)
from homeassistant.util.limited_size_dict import LimitedSizeDict
+from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadEnhancer
from .const import (
+ BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
DATA_LAST_WAKE_UP,
DATA_MIGRATIONS,
DOMAIN,
+ MS_PER_CHUNK,
+ SAMPLE_CHANNELS,
+ SAMPLE_RATE,
+ SAMPLE_WIDTH,
+ SAMPLES_PER_CHUNK,
WAKE_WORD_COOLDOWN,
)
from .error import (
@@ -111,9 +115,6 @@ def validate_language(data: dict[str, Any]) -> Any:
SAVE_DELAY = 10
-AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz
-AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples
-
@callback
def _async_resolve_default_pipeline_settings(
@@ -503,8 +504,8 @@ class AudioSettings:
is_vad_enabled: bool = True
"""True if VAD is used to determine the end of the voice command."""
- is_chunking_enabled: bool = True
- """True if audio is automatically split into 10 ms chunks (required for VAD, etc.)"""
+ silence_seconds: float = 0.5
+ """Seconds of silence after voice command has ended."""
def __post_init__(self) -> None:
"""Verify settings post-initialization."""
@@ -514,9 +515,6 @@ def __post_init__(self) -> None:
if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31):
raise ValueError("auto_gain_dbfs must be in [0, 31]")
- if self.needs_processor and (not self.is_chunking_enabled):
- raise ValueError("Chunking must be enabled for audio processing")
-
@property
def needs_processor(self) -> bool:
"""True if an audio processor is needed."""
@@ -527,20 +525,6 @@ def needs_processor(self) -> bool:
)
-@dataclass(frozen=True, slots=True)
-class ProcessedAudioChunk:
- """Processed audio chunk and metadata."""
-
- audio: bytes
- """Raw PCM audio @ 16Khz with 16-bit mono samples"""
-
- timestamp_ms: int
- """Timestamp relative to start of audio stream (milliseconds)"""
-
- is_speech: bool | None
- """True if audio chunk likely contains speech, False if not, None if unknown"""
-
-
@dataclass
class PipelineRun:
"""Running context for a pipeline."""
@@ -573,10 +557,12 @@ class PipelineRun:
debug_recording_queue: Queue[str | bytes | None] | None = None
"""Queue to communicate with debug recording thread"""
- audio_processor: AudioProcessor | None = None
+ audio_enhancer: AudioEnhancer | None = None
"""VAD/noise suppression/auto gain"""
- audio_processor_buffer: AudioBuffer = field(init=False, repr=False)
+ audio_chunking_buffer: AudioBuffer = field(
+ default_factory=lambda: AudioBuffer(BYTES_PER_CHUNK)
+ )
"""Buffer used when splitting audio into chunks for audio processing"""
_device_id: str | None = None
@@ -601,17 +587,12 @@ def __post_init__(self) -> None:
pipeline_data.pipeline_runs.add_run(self)
# Initialize with audio settings
- self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES)
- if self.audio_settings.needs_processor:
- # Delay import of webrtc so HA start up is not crashing
- # on older architectures (armhf).
- #
- # pylint: disable=import-outside-toplevel
- from webrtc_noise_gain import AudioProcessor
-
- self.audio_processor = AudioProcessor(
+ if self.audio_settings.needs_processor and (self.audio_enhancer is None):
+ # Default audio enhancer
+ self.audio_enhancer = MicroVadEnhancer(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
+ self.audio_settings.is_vad_enabled,
)
def __eq__(self, other: object) -> bool:
@@ -688,8 +669,8 @@ async def prepare_wake_word_detection(self) -> None:
async def wake_word_detection(
self,
- stream: AsyncIterable[ProcessedAudioChunk],
- audio_chunks_for_stt: list[ProcessedAudioChunk],
+ stream: AsyncIterable[EnhancedAudioChunk],
+ audio_chunks_for_stt: list[EnhancedAudioChunk],
) -> wake_word.DetectionResult | None:
"""Run wake-word-detection portion of pipeline. Returns detection result."""
metadata_dict = asdict(
@@ -732,10 +713,11 @@ async def wake_word_detection(
# Audio chunk buffer. This audio will be forwarded to speech-to-text
# after wake-word-detection.
num_audio_chunks_to_buffer = int(
- (wake_word_settings.audio_seconds_to_buffer * 16000)
- / AUDIO_PROCESSOR_SAMPLES
+ (wake_word_settings.audio_seconds_to_buffer * SAMPLE_RATE)
+ / SAMPLES_PER_CHUNK
)
- stt_audio_buffer: deque[ProcessedAudioChunk] | None = None
+
+ stt_audio_buffer: deque[EnhancedAudioChunk] | None = None
if num_audio_chunks_to_buffer > 0:
stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer)
@@ -797,7 +779,7 @@ async def wake_word_detection(
# speech-to-text so the user does not have to pause before
# speaking the voice command.
audio_chunks_for_stt.extend(
- ProcessedAudioChunk(
+ EnhancedAudioChunk(
audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False
)
for chunk_ts in result.queued_audio
@@ -819,18 +801,17 @@ async def wake_word_detection(
async def _wake_word_audio_stream(
self,
- audio_stream: AsyncIterable[ProcessedAudioChunk],
- stt_audio_buffer: deque[ProcessedAudioChunk] | None,
+ audio_stream: AsyncIterable[EnhancedAudioChunk],
+ stt_audio_buffer: deque[EnhancedAudioChunk] | None,
wake_word_vad: VoiceActivityTimeout | None,
- sample_rate: int = 16000,
- sample_width: int = 2,
+ sample_rate: int = SAMPLE_RATE,
+ sample_width: int = SAMPLE_WIDTH,
) -> AsyncIterable[tuple[bytes, int]]:
"""Yield audio chunks with timestamps (milliseconds since start of stream).
Adds audio to a ring buffer that will be forwarded to speech-to-text after
detection. Times out if VAD detects enough silence.
"""
- chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
async for chunk in audio_stream:
if self.abort_wake_word_detection:
raise WakeWordDetectionAborted
@@ -845,6 +826,7 @@ async def _wake_word_audio_stream(
stt_audio_buffer.append(chunk)
if wake_word_vad is not None:
+ chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not wake_word_vad.process(chunk_seconds, chunk.is_speech):
raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected"
@@ -881,7 +863,7 @@ async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None:
async def speech_to_text(
self,
metadata: stt.SpeechMetadata,
- stream: AsyncIterable[ProcessedAudioChunk],
+ stream: AsyncIterable[EnhancedAudioChunk],
) -> str:
"""Run speech-to-text portion of pipeline. Returns the spoken text."""
# Create a background task to prepare the conversation agent
@@ -916,7 +898,9 @@ async def speech_to_text(
# Transcribe audio stream
stt_vad: VoiceCommandSegmenter | None = None
if self.audio_settings.is_vad_enabled:
- stt_vad = VoiceCommandSegmenter()
+ stt_vad = VoiceCommandSegmenter(
+ silence_seconds=self.audio_settings.silence_seconds
+ )
result = await self.stt_provider.async_process_audio_stream(
metadata,
@@ -957,18 +941,18 @@ async def speech_to_text(
async def _speech_to_text_stream(
self,
- audio_stream: AsyncIterable[ProcessedAudioChunk],
+ audio_stream: AsyncIterable[EnhancedAudioChunk],
stt_vad: VoiceCommandSegmenter | None,
- sample_rate: int = 16000,
- sample_width: int = 2,
+ sample_rate: int = SAMPLE_RATE,
+ sample_width: int = SAMPLE_WIDTH,
) -> AsyncGenerator[bytes]:
"""Yield audio chunks until VAD detects silence or speech-to-text completes."""
- chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate
sent_vad_start = False
async for chunk in audio_stream:
self._capture_chunk(chunk.audio)
if stt_vad is not None:
+ chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate
if not stt_vad.process(chunk_seconds, chunk.is_speech):
# Silence detected at the end of voice command
self.process_event(
@@ -1072,8 +1056,8 @@ async def prepare_text_to_speech(self) -> None:
tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output
if self.tts_audio_output == "wav":
# 16 Khz, 16-bit mono
- tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = 16000
- tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = 1
+ tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = SAMPLE_RATE
+ tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = SAMPLE_CHANNELS
try:
options_supported = await tts.async_support_options(
@@ -1218,53 +1202,31 @@ async def _stop_debug_recording_thread(self) -> None:
self.debug_recording_thread = None
async def process_volume_only(
- self,
- audio_stream: AsyncIterable[bytes],
- sample_rate: int = 16000,
- sample_width: int = 2,
- ) -> AsyncGenerator[ProcessedAudioChunk]:
+ self, audio_stream: AsyncIterable[bytes]
+ ) -> AsyncGenerator[EnhancedAudioChunk]:
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
- ms_per_sample = sample_rate // 1000
- ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
timestamp_ms = 0
-
async for chunk in audio_stream:
if self.audio_settings.volume_multiplier != 1.0:
chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier)
- if self.audio_settings.is_chunking_enabled:
- # 10 ms chunking
- for chunk_10ms in chunk_samples(
- chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
- ):
- yield ProcessedAudioChunk(
- audio=chunk_10ms,
- timestamp_ms=timestamp_ms,
- is_speech=None, # no VAD
- )
- timestamp_ms += ms_per_chunk
- else:
- # No chunking
- yield ProcessedAudioChunk(
- audio=chunk,
+ for sub_chunk in chunk_samples(
+ chunk, BYTES_PER_CHUNK, self.audio_chunking_buffer
+ ):
+ yield EnhancedAudioChunk(
+ audio=sub_chunk,
timestamp_ms=timestamp_ms,
is_speech=None, # no VAD
)
- timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
+ timestamp_ms += MS_PER_CHUNK
async def process_enhance_audio(
- self,
- audio_stream: AsyncIterable[bytes],
- sample_rate: int = 16000,
- sample_width: int = 2,
- ) -> AsyncGenerator[ProcessedAudioChunk]:
- """Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation."""
- assert self.audio_processor is not None
-
- ms_per_sample = sample_rate // 1000
- ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample
- timestamp_ms = 0
+ self, audio_stream: AsyncIterable[bytes]
+ ) -> AsyncGenerator[EnhancedAudioChunk]:
+ """Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
+ assert self.audio_enhancer is not None
+ timestamp_ms = 0
async for dirty_samples in audio_stream:
if self.audio_settings.volume_multiplier != 1.0:
# Static gain
@@ -1272,18 +1234,12 @@ async def process_enhance_audio(
dirty_samples, self.audio_settings.volume_multiplier
)
- # Split into 10ms chunks for audio enhancements/VAD
- for dirty_10ms_chunk in chunk_samples(
- dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer
+ # Split into chunks for audio enhancements/VAD
+ for dirty_chunk in chunk_samples(
+ dirty_samples, BYTES_PER_CHUNK, self.audio_chunking_buffer
):
- ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk)
- yield ProcessedAudioChunk(
- audio=ap_result.audio,
- timestamp_ms=timestamp_ms,
- is_speech=ap_result.is_speech,
- )
-
- timestamp_ms += ms_per_chunk
+ yield self.audio_enhancer.enhance_chunk(dirty_chunk, timestamp_ms)
+ timestamp_ms += MS_PER_CHUNK
def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
@@ -1323,9 +1279,9 @@ def _pipeline_debug_recording_thread_proc(
wav_path = run_recording_dir / f"{message}.wav"
wav_writer = wave.open(str(wav_path), "wb")
- wav_writer.setframerate(16000)
- wav_writer.setsampwidth(2)
- wav_writer.setnchannels(1)
+ wav_writer.setframerate(SAMPLE_RATE)
+ wav_writer.setsampwidth(SAMPLE_WIDTH)
+ wav_writer.setnchannels(SAMPLE_CHANNELS)
elif isinstance(message, bytes):
# Chunk of 16-bit mono audio at 16Khz
if wav_writer is not None:
@@ -1368,8 +1324,8 @@ async def execute(self) -> None:
"""Run pipeline."""
self.run.start(device_id=self.device_id)
current_stage: PipelineStage | None = self.run.start_stage
- stt_audio_buffer: list[ProcessedAudioChunk] = []
- stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None
+ stt_audio_buffer: list[EnhancedAudioChunk] = []
+ stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
if self.stt_stream is not None:
if self.run.audio_settings.needs_processor:
@@ -1423,7 +1379,7 @@ async def execute(self) -> None:
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> (
- AsyncGenerator[ProcessedAudioChunk]
+ AsyncGenerator[EnhancedAudioChunk]
):
# Buffered audio
for chunk in stt_audio_buffer:
diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py
index 5b3d1408f586ad..8372dbc54c7650 100644
--- a/homeassistant/components/assist_pipeline/vad.py
+++ b/homeassistant/components/assist_pipeline/vad.py
@@ -2,17 +2,14 @@
from __future__ import annotations
-from abc import ABC, abstractmethod
-from collections.abc import Iterable
+from collections.abc import Callable, Iterable
from dataclasses import dataclass
from enum import StrEnum
import logging
-from typing import Final, cast
-_LOGGER = logging.getLogger(__name__)
+from .const import SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH
-_SAMPLE_RATE: Final = 16000 # Hz
-_SAMPLE_WIDTH: Final = 2 # bytes
+_LOGGER = logging.getLogger(__name__)
class VadSensitivity(StrEnum):
@@ -27,50 +24,12 @@ def to_seconds(sensitivity: VadSensitivity | str) -> float:
"""Return seconds of silence for sensitivity level."""
sensitivity = VadSensitivity(sensitivity)
if sensitivity == VadSensitivity.RELAXED:
- return 2.0
+ return 1.25
if sensitivity == VadSensitivity.AGGRESSIVE:
- return 0.5
-
- return 1.0
-
-
-class VoiceActivityDetector(ABC):
- """Base class for voice activity detectors (VAD)."""
-
- @abstractmethod
- def is_speech(self, chunk: bytes) -> bool:
- """Return True if audio chunk contains speech."""
-
- @property
- @abstractmethod
- def samples_per_chunk(self) -> int | None:
- """Return number of samples per chunk or None if chunking is not required."""
-
+ return 0.25
-class WebRtcVad(VoiceActivityDetector):
- """Voice activity detector based on webrtc."""
-
- def __init__(self) -> None:
- """Initialize webrtcvad."""
- # Delay import of webrtc so HA start up is not crashing
- # on older architectures (armhf).
- #
- # pylint: disable=import-outside-toplevel
- from webrtc_noise_gain import AudioProcessor
-
- # Just VAD: no noise suppression or auto gain
- self._audio_processor = AudioProcessor(0, 0)
-
- def is_speech(self, chunk: bytes) -> bool:
- """Return True if audio chunk contains speech."""
- result = self._audio_processor.Process10ms(chunk)
- return cast(bool, result.is_speech)
-
- @property
- def samples_per_chunk(self) -> int | None:
- """Return 10 ms."""
- return int(0.01 * _SAMPLE_RATE) # 10 ms
+ return 0.7
class AudioBuffer:
@@ -119,7 +78,7 @@ class VoiceCommandSegmenter:
speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
- silence_seconds: float = 0.5
+ silence_seconds: float = 0.7
"""Seconds of silence after voice command has ended."""
timeout_seconds: float = 15.0
@@ -131,6 +90,9 @@ class VoiceCommandSegmenter:
in_command: bool = False
"""True if inside voice command."""
+ timed_out: bool = False
+ """True a timeout occurred during voice command."""
+
_speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started."""
@@ -160,6 +122,9 @@ def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
Returns False when command is done.
"""
+ if self.timed_out:
+ self.timed_out = False
+
self._timeout_seconds_left -= chunk_seconds
if self._timeout_seconds_left <= 0:
_LOGGER.warning(
@@ -167,6 +132,7 @@ def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
self.timeout_seconds,
)
self.reset()
+ self.timed_out = True
return False
if not self.in_command:
@@ -176,29 +142,38 @@ def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
if self._speech_seconds_left <= 0:
# Inside voice command
self.in_command = True
+ self._silence_seconds_left = self.silence_seconds
+ _LOGGER.debug("Voice command started")
else:
# Reset if enough silence
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds
+ self._reset_seconds_left = self.reset_seconds
elif not is_speech:
+ # Silence in command
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
if self._silence_seconds_left <= 0:
+ # Command finished successfully
self.reset()
+ _LOGGER.debug("Voice command finished")
return False
else:
- # Reset if enough speech
+ # Speech in command.
+ # Reset silence counter if enough speech.
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
+ self._reset_seconds_left = self.reset_seconds
return True
def process_with_vad(
self,
chunk: bytes,
- vad: VoiceActivityDetector,
+ vad_samples_per_chunk: int | None,
+ vad_is_speech: Callable[[bytes], bool],
leftover_chunk_buffer: AudioBuffer | None,
) -> bool:
"""Process an audio chunk using an external VAD.
@@ -207,20 +182,22 @@ def process_with_vad(
Returns False when voice command is finished.
"""
- if vad.samples_per_chunk is None:
+ if vad_samples_per_chunk is None:
# No chunking
- chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE
- is_speech = vad.is_speech(chunk)
+ chunk_seconds = (
+ len(chunk) // (SAMPLE_WIDTH * SAMPLE_CHANNELS)
+ ) / SAMPLE_RATE
+ is_speech = vad_is_speech(chunk)
return self.process(chunk_seconds, is_speech)
if leftover_chunk_buffer is None:
raise ValueError("leftover_chunk_buffer is required when vad uses chunking")
# With chunking
- seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE
- bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH
+ seconds_per_chunk = vad_samples_per_chunk / SAMPLE_RATE
+ bytes_per_chunk = vad_samples_per_chunk * (SAMPLE_WIDTH * SAMPLE_CHANNELS)
for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer):
- is_speech = vad.is_speech(vad_chunk)
+ is_speech = vad_is_speech(vad_chunk)
if not self.process(seconds_per_chunk, is_speech):
return False
diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py
index 3855bd7afc5112..c96af655589934 100644
--- a/homeassistant/components/assist_pipeline/websocket_api.py
+++ b/homeassistant/components/assist_pipeline/websocket_api.py
@@ -24,6 +24,9 @@
DEFAULT_WAKE_WORD_TIMEOUT,
DOMAIN,
EVENT_RECORDING,
+ SAMPLE_CHANNELS,
+ SAMPLE_RATE,
+ SAMPLE_WIDTH,
)
from .error import PipelineNotFound
from .pipeline import (
@@ -92,7 +95,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None:
vol.Optional("volume_multiplier"): float,
# Advanced use cases/testing
vol.Optional("no_vad"): bool,
- vol.Optional("no_chunking"): bool,
}
},
extra=vol.ALLOW_EXTRA,
@@ -170,9 +172,14 @@ async def stt_stream() -> AsyncGenerator[bytes]:
# Yield until we receive an empty chunk
while chunk := await audio_queue.get():
- if incoming_sample_rate != 16000:
+ if incoming_sample_rate != SAMPLE_RATE:
chunk, state = audioop.ratecv(
- chunk, 2, 1, incoming_sample_rate, 16000, state
+ chunk,
+ SAMPLE_WIDTH,
+ SAMPLE_CHANNELS,
+ incoming_sample_rate,
+ SAMPLE_RATE,
+ state,
)
yield chunk
@@ -206,7 +213,6 @@ def handle_binary(
auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0),
volume_multiplier=msg_input.get("volume_multiplier", 1.0),
is_vad_enabled=not msg_input.get("no_vad", False),
- is_chunking_enabled=not msg_input.get("no_chunking", False),
)
elif start_stage == PipelineStage.INTENT:
# Input to conversation agent
@@ -424,9 +430,9 @@ def websocket_list_languages(
connection.send_result(
msg["id"],
{
- "languages": sorted(pipeline_languages)
- if pipeline_languages
- else pipeline_languages
+ "languages": (
+ sorted(pipeline_languages) if pipeline_languages else pipeline_languages
+ )
},
)
diff --git a/homeassistant/components/asterisk_cdr/__init__.py b/homeassistant/components/asterisk_cdr/__init__.py
deleted file mode 100644
index d681a392c56ae5..00000000000000
--- a/homeassistant/components/asterisk_cdr/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The asterisk_cdr component."""
diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py
deleted file mode 100644
index fde4826fcee38b..00000000000000
--- a/homeassistant/components/asterisk_cdr/mailbox.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Support for the Asterisk CDR interface."""
-
-from __future__ import annotations
-
-import datetime
-import hashlib
-from typing import Any
-
-from homeassistant.components.asterisk_mbox import (
- DOMAIN as ASTERISK_DOMAIN,
- SIGNAL_CDR_UPDATE,
-)
-from homeassistant.components.mailbox import Mailbox
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-MAILBOX_NAME = "asterisk_cdr"
-
-
-async def async_get_handler(
- hass: HomeAssistant,
- config: ConfigType,
- discovery_info: DiscoveryInfoType | None = None,
-) -> Mailbox:
- """Set up the Asterix CDR platform."""
- return AsteriskCDR(hass, MAILBOX_NAME)
-
-
-class AsteriskCDR(Mailbox):
- """Asterisk VM Call Data Record mailbox."""
-
- def __init__(self, hass: HomeAssistant, name: str) -> None:
- """Initialize Asterisk CDR."""
- super().__init__(hass, name)
- self.cdr: list[dict[str, Any]] = []
- async_dispatcher_connect(self.hass, SIGNAL_CDR_UPDATE, self._update_callback)
-
- @callback
- def _update_callback(self, msg: list[dict[str, Any]]) -> Any:
- """Update the message count in HA, if needed."""
- self._build_message()
- self.async_update()
-
- def _build_message(self) -> None:
- """Build message structure."""
- cdr: list[dict[str, Any]] = []
- for entry in self.hass.data[ASTERISK_DOMAIN].cdr:
- timestamp = datetime.datetime.strptime(
- entry["time"], "%Y-%m-%d %H:%M:%S"
- ).timestamp()
- info = {
- "origtime": timestamp,
- "callerid": entry["callerid"],
- "duration": entry["duration"],
- }
- sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest()
- msg = (
- f"Destination: {entry['dest']}\n"
- f"Application: {entry['application']}\n "
- f"Context: {entry['context']}"
- )
- cdr.append({"info": info, "sha": sha, "text": msg})
- self.cdr = cdr
-
- async def async_get_messages(self) -> list[dict[str, Any]]:
- """Return a list of the current messages."""
- if not self.cdr:
- self._build_message()
- return self.cdr
diff --git a/homeassistant/components/asterisk_cdr/manifest.json b/homeassistant/components/asterisk_cdr/manifest.json
deleted file mode 100644
index 581b9dfb9a5109..00000000000000
--- a/homeassistant/components/asterisk_cdr/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "domain": "asterisk_cdr",
- "name": "Asterisk Call Detail Records",
- "codeowners": [],
- "dependencies": ["asterisk_mbox"],
- "documentation": "https://www.home-assistant.io/integrations/asterisk_cdr",
- "iot_class": "local_polling"
-}
diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py
deleted file mode 100644
index 3e3913b7d42bfb..00000000000000
--- a/homeassistant/components/asterisk_mbox/__init__.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Support for Asterisk Voicemail interface."""
-
-import logging
-from typing import Any, cast
-
-from asterisk_mbox import Client as asteriskClient
-from asterisk_mbox.commands import (
- CMD_MESSAGE_CDR,
- CMD_MESSAGE_CDR_AVAILABLE,
- CMD_MESSAGE_LIST,
-)
-import voluptuous as vol
-
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_connect
-from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
-from homeassistant.helpers.typing import ConfigType
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = "asterisk_mbox"
-
-SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform"
-SIGNAL_MESSAGE_REQUEST = "asterisk_mbox.message_request"
-SIGNAL_MESSAGE_UPDATE = "asterisk_mbox.message_updated"
-SIGNAL_CDR_UPDATE = "asterisk_mbox.message_updated"
-SIGNAL_CDR_REQUEST = "asterisk_mbox.message_request"
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_PORT): cv.port,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up for the Asterisk Voicemail box."""
- conf: dict[str, Any] = config[DOMAIN]
-
- host: str = conf[CONF_HOST]
- port: int = conf[CONF_PORT]
- password: str = conf[CONF_PASSWORD]
-
- hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config)
- create_issue(
- hass,
- DOMAIN,
- "deprecated_integration",
- breaks_in_ha_version="2024.9.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_integration",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Asterisk Voicemail",
- "mailbox": "mailbox",
- },
- )
-
- return True
-
-
-class AsteriskData:
- """Store Asterisk mailbox data."""
-
- def __init__(
- self,
- hass: HomeAssistant,
- host: str,
- port: int,
- password: str,
- config: dict[str, Any],
- ) -> None:
- """Init the Asterisk data object."""
-
- self.hass = hass
- self.config = config
- self.messages: list[dict[str, Any]] | None = None
- self.cdr: list[dict[str, Any]] | None = None
-
- dispatcher_connect(self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages)
- dispatcher_connect(self.hass, SIGNAL_CDR_REQUEST, self._request_cdr)
- dispatcher_connect(self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform)
- # Only connect after signal connection to ensure we don't miss any
- self.client = asteriskClient(host, port, password, self.handle_data)
-
- @callback
- def _discover_platform(self, component: str) -> None:
- _LOGGER.debug("Adding mailbox %s", component)
- self.hass.async_create_task(
- discovery.async_load_platform(
- self.hass, "mailbox", component, {}, self.config
- )
- )
-
- @callback
- def handle_data(
- self, command: int, msg: list[dict[str, Any]] | dict[str, Any]
- ) -> None:
- """Handle changes to the mailbox."""
-
- if command == CMD_MESSAGE_LIST:
- msg = cast(list[dict[str, Any]], msg)
- _LOGGER.debug("AsteriskVM sent updated message list: Len %d", len(msg))
- old_messages = self.messages
- self.messages = sorted(
- msg, key=lambda item: item["info"]["origtime"], reverse=True
- )
- if not isinstance(old_messages, list):
- async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, DOMAIN)
- async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, self.messages)
- elif command == CMD_MESSAGE_CDR:
- msg = cast(dict[str, Any], msg)
- _LOGGER.debug(
- "AsteriskVM sent updated CDR list: Len %d", len(msg.get("entries", []))
- )
- self.cdr = msg["entries"]
- async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr)
- elif command == CMD_MESSAGE_CDR_AVAILABLE:
- if not isinstance(self.cdr, list):
- _LOGGER.debug("AsteriskVM adding CDR platform")
- self.cdr = []
- async_dispatcher_send(
- self.hass, SIGNAL_DISCOVER_PLATFORM, "asterisk_cdr"
- )
- async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST)
- else:
- _LOGGER.debug(
- "AsteriskVM sent unknown message '%d' len: %d", command, len(msg)
- )
-
- @callback
- def _request_messages(self) -> None:
- """Handle changes to the mailbox."""
- _LOGGER.debug("Requesting message list")
- self.client.messages()
-
- @callback
- def _request_cdr(self) -> None:
- """Handle changes to the CDR."""
- _LOGGER.debug("Requesting CDR list")
- self.client.get_cdr()
diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py
deleted file mode 100644
index 14d54596eea270..00000000000000
--- a/homeassistant/components/asterisk_mbox/mailbox.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Support for the Asterisk Voicemail interface."""
-
-from __future__ import annotations
-
-from functools import partial
-import logging
-from typing import Any
-
-from asterisk_mbox import ServerError
-
-from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-from . import DOMAIN as ASTERISK_DOMAIN, AsteriskData
-
-_LOGGER = logging.getLogger(__name__)
-
-SIGNAL_MESSAGE_REQUEST = "asterisk_mbox.message_request"
-SIGNAL_MESSAGE_UPDATE = "asterisk_mbox.message_updated"
-
-
-async def async_get_handler(
- hass: HomeAssistant,
- config: ConfigType,
- discovery_info: DiscoveryInfoType | None = None,
-) -> Mailbox:
- """Set up the Asterix VM platform."""
- return AsteriskMailbox(hass, ASTERISK_DOMAIN)
-
-
-class AsteriskMailbox(Mailbox):
- """Asterisk VM Sensor."""
-
- def __init__(self, hass: HomeAssistant, name: str) -> None:
- """Initialize Asterisk mailbox."""
- super().__init__(hass, name)
- async_dispatcher_connect(
- self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback
- )
-
- @callback
- def _update_callback(self, msg: str) -> None:
- """Update the message count in HA, if needed."""
- self.async_update()
-
- @property
- def media_type(self) -> str:
- """Return the supported media type."""
- return CONTENT_TYPE_MPEG
-
- @property
- def can_delete(self) -> bool:
- """Return if messages can be deleted."""
- return True
-
- @property
- def has_media(self) -> bool:
- """Return if messages have attached media files."""
- return True
-
- async def async_get_media(self, msgid: str) -> bytes:
- """Return the media blob for the msgid."""
-
- data: AsteriskData = self.hass.data[ASTERISK_DOMAIN]
- client = data.client
- try:
- return await self.hass.async_add_executor_job(
- partial(client.mp3, msgid, sync=True)
- )
- except ServerError as err:
- raise StreamError(err) from err
-
- async def async_get_messages(self) -> list[dict[str, Any]]:
- """Return a list of the current messages."""
- data: AsteriskData = self.hass.data[ASTERISK_DOMAIN]
- return data.messages or []
-
- async def async_delete(self, msgid: str) -> bool:
- """Delete the specified messages."""
- data: AsteriskData = self.hass.data[ASTERISK_DOMAIN]
- client = data.client
- _LOGGER.info("Deleting: %s", msgid)
- await self.hass.async_add_executor_job(client.delete, msgid)
- return True
diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json
deleted file mode 100644
index 8348e40ba6b9aa..00000000000000
--- a/homeassistant/components/asterisk_mbox/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "domain": "asterisk_mbox",
- "name": "Asterisk Voicemail",
- "codeowners": [],
- "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox",
- "iot_class": "local_push",
- "loggers": ["asterisk_mbox"],
- "requirements": ["asterisk_mbox==0.5.0"]
-}
diff --git a/homeassistant/components/asterisk_mbox/strings.json b/homeassistant/components/asterisk_mbox/strings.json
deleted file mode 100644
index fb6c0637a644b6..00000000000000
--- a/homeassistant/components/asterisk_mbox/strings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "issues": {
- "deprecated_integration": {
- "title": "The {integration_title} is being removed",
- "description": "{integration_title} is being removed as the `{mailbox}` platform is being removed and {integration_title} supports no other platforms. Remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
- }
- }
-}
diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py
index b193787f5006bb..4e928d63666b09 100644
--- a/homeassistant/components/asuswrt/bridge.py
+++ b/homeassistant/components/asuswrt/bridge.py
@@ -52,7 +52,7 @@
SENSORS_TYPE_RATES = "sensors_rates"
SENSORS_TYPE_TEMPERATURES = "sensors_temperatures"
-WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"])
+WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) # noqa: PYI024
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py
index 18c15ad61a18e5..3523a4f7c3923c 100644
--- a/homeassistant/components/august/config_flow.py
+++ b/homeassistant/components/august/config_flow.py
@@ -8,7 +8,7 @@
import aiohttp
import voluptuous as vol
-from yalexs.authenticator import ValidationResult
+from yalexs.authenticator_common import ValidationResult
from yalexs.const import BRANDS, DEFAULT_BRAND
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
index 293c94c96299ba..13035d68dfec9d 100644
--- a/homeassistant/components/august/manifest.json
+++ b/homeassistant/components/august/manifest.json
@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
- "requirements": ["yalexs==6.4.3", "yalexs-ble==2.4.3"]
+ "requirements": ["yalexs==8.0.2", "yalexs-ble==2.4.3"]
}
diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py
index 4748210079461c..6972913ba22142 100644
--- a/homeassistant/components/august/util.py
+++ b/homeassistant/components/august/util.py
@@ -4,7 +4,6 @@
from datetime import datetime, timedelta
from functools import partial
-import socket
import aiohttp
from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, Activity, ActivityType
@@ -26,14 +25,7 @@ def async_create_august_clientsession(hass: HomeAssistant) -> aiohttp.ClientSess
# Create an aiohttp session instead of using the default one since the
# default one is likely to trigger august's WAF if another integration
# is also using Cloudflare
- #
- # The family is set to AF_INET because IPv6 keeps coming up as an issue
- # see https://github.com/home-assistant/core/issues/97146
- #
- # When https://github.com/aio-libs/aiohttp/issues/4451 is implemented
- # we can allow IPv6 again
- #
- return aiohttp_client.async_create_clientsession(hass, family=socket.AF_INET)
+ return aiohttp_client.async_create_clientsession(hass)
def retrieve_time_based_activity(
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index f2ef404ab348dd..8ab9c478bc484d 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -322,8 +322,8 @@ async def trigger_service_handler(
},
trigger_service_handler,
)
- component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
component.async_register_entity_service(
SERVICE_TURN_OFF,
{vol.Optional(CONF_STOP_ACTIONS, default=DEFAULT_STOP_ACTIONS): cv.boolean},
diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py
index 657bedcf4d7161..748b4baf621e18 100644
--- a/homeassistant/components/bang_olufsen/const.py
+++ b/homeassistant/components/bang_olufsen/const.py
@@ -68,20 +68,20 @@ class BangOlufsenModel(StrEnum):
class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
- PLAYBACK_ERROR: Final[str] = "playback_error"
- PLAYBACK_METADATA: Final[str] = "playback_metadata"
- PLAYBACK_PROGRESS: Final[str] = "playback_progress"
- PLAYBACK_SOURCE: Final[str] = "playback_source"
- PLAYBACK_STATE: Final[str] = "playback_state"
- SOFTWARE_UPDATE_STATE: Final[str] = "software_update_state"
- SOURCE_CHANGE: Final[str] = "source_change"
- VOLUME: Final[str] = "volume"
+ PLAYBACK_ERROR = "playback_error"
+ PLAYBACK_METADATA = "playback_metadata"
+ PLAYBACK_PROGRESS = "playback_progress"
+ PLAYBACK_SOURCE = "playback_source"
+ PLAYBACK_STATE = "playback_state"
+ SOFTWARE_UPDATE_STATE = "software_update_state"
+ SOURCE_CHANGE = "source_change"
+ VOLUME = "volume"
# Sub-notifications
- NOTIFICATION: Final[str] = "notification"
- REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged"
+ NOTIFICATION = "notification"
+ REMOTE_MENU_CHANGED = "remoteMenuChanged"
- ALL: Final[str] = "all"
+ ALL = "all"
DOMAIN: Final[str] = "bang_olufsen"
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index 5f8b76381258e4..8bc97858d0d5c8 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Callable
import json
import logging
from typing import Any, cast
@@ -137,65 +138,25 @@ async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
await self._initialize()
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{CONNECTION_STATUS}",
- self._async_update_connection_state,
- )
- )
-
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}",
- self._async_update_playback_error,
- )
- )
-
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}",
- self._async_update_playback_metadata,
- )
- )
+ signal_handlers: dict[str, Callable] = {
+ CONNECTION_STATUS: self._async_update_connection_state,
+ WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
+ WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata,
+ WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
+ WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
+ WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
+ WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
+ WebsocketNotification.VOLUME: self._async_update_volume,
+ }
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}",
- self._async_update_playback_progress,
- )
- )
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}",
- self._async_update_playback_state,
- )
- )
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
- self._async_update_sources,
- )
- )
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}",
- self._async_update_source_change,
- )
- )
- self.async_on_remove(
- async_dispatcher_connect(
- self.hass,
- f"{self._unique_id}_{WebsocketNotification.VOLUME}",
- self._async_update_volume,
+ for signal, signal_handler in signal_handlers.items():
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{self._unique_id}_{signal}",
+ signal_handler,
+ )
)
- )
async def _initialize(self) -> None:
"""Initialize connection dependent variables."""
diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py
index 6ced2c73c9ab7a..7157c47830c05e 100644
--- a/homeassistant/components/bbox/device_tracker.py
+++ b/homeassistant/components/bbox/device_tracker.py
@@ -39,7 +39,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> BboxDeviceScanner |
return scanner if scanner.success_init else None
-Device = namedtuple("Device", ["mac", "name", "ip", "last_update"])
+Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) # noqa: PYI024
class BboxDeviceScanner(DeviceScanner):
diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py
index fcf19adf71e836..cce9100a0bd570 100644
--- a/homeassistant/components/blink/camera.py
+++ b/homeassistant/components/blink/camera.py
@@ -51,8 +51,8 @@ async def async_setup_entry(
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(SERVICE_RECORD, {}, "record")
- platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera")
+ platform.async_register_entity_service(SERVICE_RECORD, None, "record")
+ platform.async_register_entity_service(SERVICE_TRIGGER, None, "trigger_camera")
platform.async_register_entity_service(
SERVICE_SAVE_RECENT_CLIPS,
{vol.Required(CONF_FILE_PATH): cv.string},
diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py
index 4517d134e69d7d..c231a33991abd2 100644
--- a/homeassistant/components/blueprint/importer.py
+++ b/homeassistant/components/blueprint/importer.py
@@ -245,14 +245,36 @@ async def fetch_blueprint_from_website_url(
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
+async def fetch_blueprint_from_generic_url(
+ hass: HomeAssistant, url: str
+) -> ImportedBlueprint:
+ """Get a blueprint from a generic website."""
+ session = aiohttp_client.async_get_clientsession(hass)
+
+ resp = await session.get(url, raise_for_status=True)
+ raw_yaml = await resp.text()
+ data = yaml.parse_yaml(raw_yaml)
+
+ assert isinstance(data, dict)
+ blueprint = Blueprint(data)
+
+ parsed_import_url = yarl.URL(url)
+ suggested_filename = f"{parsed_import_url.host}/{parsed_import_url.parts[-1][:-5]}"
+ return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
+
+
+FETCH_FUNCTIONS = (
+ fetch_blueprint_from_community_post,
+ fetch_blueprint_from_github_url,
+ fetch_blueprint_from_github_gist_url,
+ fetch_blueprint_from_website_url,
+ fetch_blueprint_from_generic_url,
+)
+
+
async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint:
"""Get a blueprint from a url."""
- for func in (
- fetch_blueprint_from_community_post,
- fetch_blueprint_from_github_url,
- fetch_blueprint_from_github_gist_url,
- fetch_blueprint_from_website_url,
- ):
+ for func in FETCH_FUNCTIONS:
with suppress(UnsupportedUrl):
imported_bp = await func(hass, url)
imported_bp.blueprint.update_metadata(source_url=url)
diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py
index 0912a584fcef9d..cbe95fc3abf8c9 100644
--- a/homeassistant/components/bluesound/__init__.py
+++ b/homeassistant/components/bluesound/__init__.py
@@ -67,15 +67,4 @@ async def async_setup_entry(
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- player = None
- for player in hass.data[DOMAIN]:
- if player.unique_id == config_entry.unique_id:
- break
-
- if player is None:
- return False
-
- player.stop_polling()
- hass.data[DOMAIN].remove(player)
-
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index b320566c74aac0..92f47977ee5600 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -3,8 +3,7 @@
from __future__ import annotations
import asyncio
-from asyncio import CancelledError
-from collections.abc import Callable
+from asyncio import CancelledError, Task
from contextlib import suppress
from datetime import datetime, timedelta
import logging
@@ -31,15 +30,11 @@
CONF_HOSTS,
CONF_NAME,
CONF_PORT,
- EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
- Event,
HomeAssistant,
ServiceCall,
- callback,
)
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ServiceValidationError
@@ -50,7 +45,6 @@
format_mac,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
@@ -123,59 +117,6 @@ class ServiceMethodDetails(NamedTuple):
}
-def _add_player(
- hass: HomeAssistant,
- async_add_entities: AddEntitiesCallback,
- host: str,
- port: int,
- player: Player,
- sync_status: SyncStatus,
-):
- """Add Bluesound players."""
-
- @callback
- def _init_bluesound_player(event: Event | None = None):
- """Start polling."""
- hass.async_create_task(bluesound_player.async_init())
-
- @callback
- def _start_polling(event: Event | None = None):
- """Start polling."""
- bluesound_player.start_polling()
-
- @callback
- def _stop_polling(event: Event | None = None):
- """Stop polling."""
- bluesound_player.stop_polling()
-
- @callback
- def _add_bluesound_player_cb():
- """Add player after first sync fetch."""
- if bluesound_player.id in [x.id for x in hass.data[DATA_BLUESOUND]]:
- _LOGGER.warning("Player already added %s", bluesound_player.id)
- return
-
- hass.data[DATA_BLUESOUND].append(bluesound_player)
- async_add_entities([bluesound_player])
- _LOGGER.debug("Added device with name: %s", bluesound_player.name)
-
- if hass.is_running:
- _start_polling()
- else:
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_polling)
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
-
- bluesound_player = BluesoundPlayer(
- hass, host, port, player, sync_status, _add_bluesound_player_cb
- )
-
- if hass.is_running:
- _init_bluesound_player()
- else:
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_bluesound_player)
-
-
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Import config entry from configuration.yaml."""
if not hass.config_entries.async_entries(DOMAIN):
@@ -252,18 +193,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Bluesound entry."""
- host = config_entry.data[CONF_HOST]
- port = config_entry.data[CONF_PORT]
-
- _add_player(
- hass,
- async_add_entities,
- host,
- port,
+ bluesound_player = BluesoundPlayer(
+ config_entry.data[CONF_HOST],
+ config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
config_entry.runtime_data.sync_status,
)
+ hass.data[DATA_BLUESOUND].append(bluesound_player)
+ async_add_entities([bluesound_player])
+
async def async_setup_platform(
hass: HomeAssistant,
@@ -290,39 +229,32 @@ class BluesoundPlayer(MediaPlayerEntity):
def __init__(
self,
- hass: HomeAssistant,
host: str,
port: int,
player: Player,
sync_status: SyncStatus,
- init_callback: Callable[[], None],
) -> None:
"""Initialize the media player."""
self.host = host
- self._hass = hass
self.port = port
- self._polling_task = None # The actual polling task.
- self._id = None
+ self._polling_task: Task[None] | None = None # The actual polling task.
+ self._id = sync_status.id
self._last_status_update = None
- self._sync_status: SyncStatus | None = None
+ self._sync_status = sync_status
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
- self._is_online = False
- self._retry_remove = None
self._muted = False
self._master: BluesoundPlayer | None = None
self._is_master = False
self._group_name = None
self._group_list: list[str] = []
- self._bluesound_device_name = None
+ self._bluesound_device_name = sync_status.name
self._player = player
- self._init_callback = init_callback
-
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
- if port is DEFAULT_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))},
@@ -349,23 +281,18 @@ def _try_get_index(string, search_string):
except ValueError:
return -1
- async def force_update_sync_status(self, on_updated_cb=None) -> bool:
+ async def force_update_sync_status(self) -> bool:
"""Update the internal status."""
sync_status = await self._player.sync_status()
self._sync_status = sync_status
- if not self._id:
- self._id = sync_status.id
- if not self._bluesound_device_name:
- self._bluesound_device_name = sync_status.name
-
if sync_status.master is not None:
self._is_master = False
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
master_device = [
device
- for device in self._hass.data[DATA_BLUESOUND]
+ for device in self.hass.data[DATA_BLUESOUND]
if device.id == master_id
]
@@ -380,57 +307,51 @@ async def force_update_sync_status(self, on_updated_cb=None) -> bool:
slaves = self._sync_status.slaves
self._is_master = slaves is not None
- if on_updated_cb:
- on_updated_cb()
return True
async def _start_poll_command(self):
"""Loop which polls the status of the player."""
- try:
- while True:
+ while True:
+ try:
await self.async_update_status()
-
- except (TimeoutError, ClientError):
- _LOGGER.error("Node %s:%s is offline, retrying later", self.name, self.port)
- await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
- self.start_polling()
-
- except CancelledError:
- _LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port)
- except Exception:
- _LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
- raise
-
- def start_polling(self):
+ except (TimeoutError, ClientError):
+ _LOGGER.error(
+ "Node %s:%s is offline, retrying later", self.host, self.port
+ )
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
+ except CancelledError:
+ _LOGGER.debug(
+ "Stopping the polling of node %s:%s", self.host, self.port
+ )
+ return
+ except Exception:
+ _LOGGER.exception(
+ "Unexpected error in %s:%s, retrying later", self.host, self.port
+ )
+ await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
+
+ async def async_added_to_hass(self) -> None:
"""Start the polling task."""
- self._polling_task = self._hass.async_create_task(self._start_poll_command())
+ await super().async_added_to_hass()
+
+ self._polling_task = self.hass.async_create_background_task(
+ self._start_poll_command(),
+ name=f"bluesound.polling_{self.host}:{self.port}",
+ )
- def stop_polling(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Stop the polling task."""
- self._polling_task.cancel()
+ await super().async_will_remove_from_hass()
- async def async_init(self, triggered=None):
- """Initialize the player async."""
- try:
- if self._retry_remove is not None:
- self._retry_remove()
- self._retry_remove = None
+ assert self._polling_task is not None
+ if self._polling_task.cancel():
+ await self._polling_task
- await self.force_update_sync_status(self._init_callback)
- except (TimeoutError, ClientError):
- _LOGGER.error("Node %s:%s is offline, retrying later", self.host, self.port)
- self._retry_remove = async_track_time_interval(
- self._hass, self.async_init, NODE_RETRY_INITIATION
- )
- except Exception:
- _LOGGER.exception(
- "Unexpected when initiating error in %s:%s", self.host, self.port
- )
- raise
+ self.hass.data[DATA_BLUESOUND].remove(self)
async def async_update(self) -> None:
"""Update internal status of the entity."""
- if not self._is_online:
+ if not self.available:
return
with suppress(TimeoutError):
@@ -447,7 +368,7 @@ async def async_update_status(self):
try:
status = await self._player.status(etag=etag, poll_timeout=120, timeout=125)
- self._is_online = True
+ self._attr_available = True
self._last_status_update = dt_util.utcnow()
self._status = status
@@ -476,7 +397,7 @@ async def async_update_status(self):
self.async_write_ha_state()
except (TimeoutError, ClientError):
- self._is_online = False
+ self._attr_available = False
self._last_status_update = None
self._status = None
self.async_write_ha_state()
@@ -490,13 +411,13 @@ async def async_trigger_sync_on_all(self):
"""Trigger sync status update on all devices."""
_LOGGER.debug("Trigger sync status on all devices")
- for player in self._hass.data[DATA_BLUESOUND]:
+ for player in self.hass.data[DATA_BLUESOUND]:
await player.force_update_sync_status()
@Throttle(SYNC_STATUS_INTERVAL)
- async def async_update_sync_status(self, on_updated_cb=None):
+ async def async_update_sync_status(self):
"""Update sync status."""
- await self.force_update_sync_status(on_updated_cb)
+ await self.force_update_sync_status()
@Throttle(UPDATE_CAPTURE_INTERVAL)
async def async_update_captures(self) -> list[Input] | None:
@@ -615,7 +536,7 @@ def volume_level(self) -> float | None:
if self._status is not None:
volume = self._status.volume
- if self.is_grouped and self._sync_status is not None:
+ if self.is_grouped:
volume = self._sync_status.volume
if volume is None:
@@ -630,7 +551,7 @@ def is_volume_muted(self) -> bool:
if self._status is not None:
mute = self._status.mute
- if self.is_grouped and self._sync_status is not None:
+ if self.is_grouped:
mute = self._sync_status.mute_volume is not None
return mute
@@ -778,7 +699,7 @@ def rebuild_bluesound_group(self) -> list[str]:
device_group = self._group_name.split("+")
sorted_entities = sorted(
- self._hass.data[DATA_BLUESOUND],
+ self.hass.data[DATA_BLUESOUND],
key=lambda entity: entity.is_master,
reverse=True,
)
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index 12bb37ac570536..95d2b171c9f24b 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -18,7 +18,7 @@
"bleak-retry-connector==3.5.0",
"bluetooth-adapters==0.19.3",
"bluetooth-auto-recovery==1.4.2",
- "bluetooth-data-tools==1.19.3",
+ "bluetooth-data-tools==1.19.4",
"dbus-fast==2.22.1",
"habluetooth==3.1.3"
]
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index 495359ca314a92..9e43cfc418758a 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -23,8 +23,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
SERVICE_SCHEMA = vol.Schema(
vol.Any(
{vol.Required(ATTR_VIN): cv.string},
diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py
index 3bff7fe754e281..c3cf23e4fadd9d 100644
--- a/homeassistant/components/bond/light.py
+++ b/homeassistant/components/bond/light.py
@@ -52,7 +52,7 @@ async def async_setup_entry(
for service in ENTITY_SERVICES:
platform.async_register_entity_service(
service,
- {},
+ None,
f"async_{service}",
)
diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index 9a471329ba9fbf..113a582f403b6e 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -2,7 +2,7 @@
import dataclasses
-from bsblan import BSBLAN, Device, Info, StaticState
+from bsblan import BSBLAN, BSBLANConfig, Device, Info, StaticState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -35,22 +35,28 @@ class HomeAssistantBSBLANData:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BSB-Lan from a config entry."""
- session = async_get_clientsession(hass)
- bsblan = BSBLAN(
- entry.data[CONF_HOST],
+ # create config using BSBLANConfig
+ config = BSBLANConfig(
+ host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
port=entry.data[CONF_PORT],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
- session=session,
)
+ # create BSBLAN client
+ session = async_get_clientsession(hass)
+ bsblan = BSBLAN(config, session)
+
+ # Create and perform first refresh of the coordinator
coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan)
await coordinator.async_config_entry_first_refresh()
+ # Fetch all required data concurrently
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
+
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantBSBLANData(
client=bsblan,
coordinator=coordinator,
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index 1b300e1e738a7d..4d6514251cbe44 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -103,7 +103,7 @@ def __init__(
self._attr_min_temp = float(static.min_temp.value)
self._attr_max_temp = float(static.max_temp.value)
# check if self.coordinator.data.current_temperature.unit is "°C" or "°C"
- if self.coordinator.data.current_temperature.unit in ("°C", "°C"):
+ if static.min_temp.unit in ("°C", "°C"):
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
else:
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py
index 9732f0a77a958b..a1d7d6d403a86b 100644
--- a/homeassistant/components/bsblan/config_flow.py
+++ b/homeassistant/components/bsblan/config_flow.py
@@ -4,7 +4,7 @@
from typing import Any
-from bsblan import BSBLAN, BSBLANError
+from bsblan import BSBLAN, BSBLANConfig, BSBLANError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -80,15 +80,15 @@ def _async_create_entry(self) -> ConfigFlowResult:
async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None:
"""Get device information from an BSBLAN device."""
- session = async_get_clientsession(self.hass)
- bsblan = BSBLAN(
+ config = BSBLANConfig(
host=self.host,
- username=self.username,
- password=self.password,
passkey=self.passkey,
port=self.port,
- session=session,
+ username=self.username,
+ password=self.password,
)
+ session = async_get_clientsession(self.hass)
+ bsblan = BSBLAN(config, session)
device = await bsblan.device()
self.mac = device.MAC
diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py
index 5bca20cb4d4561..25d9dec865ba87 100644
--- a/homeassistant/components/bsblan/const.py
+++ b/homeassistant/components/bsblan/const.py
@@ -21,6 +21,4 @@
CONF_PASSKEY: Final = "passkey"
-CONF_DEVICE_IDENT: Final = "RVS21.831F/127"
-
DEFAULT_PORT: Final = 80
diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py
index 0bceed0bf231fd..a24082fd698a2e 100644
--- a/homeassistant/components/bsblan/diagnostics.py
+++ b/homeassistant/components/bsblan/diagnostics.py
@@ -17,7 +17,7 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
data: HomeAssistantBSBLANData = hass.data[DOMAIN][entry.entry_id]
return {
- "info": data.info.dict(),
- "device": data.device.dict(),
- "state": data.coordinator.data.dict(),
+ "info": data.info.to_dict(),
+ "device": data.device.to_dict(),
+ "state": data.coordinator.data.to_dict(),
}
diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json
index 3f58fbe364c1e7..6cd8608c42db24 100644
--- a/homeassistant/components/bsblan/manifest.json
+++ b/homeassistant/components/bsblan/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
- "requirements": ["python-bsblan==0.5.18"]
+ "requirements": ["python-bsblan==0.6.2"]
}
diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py
index 10c8000fb93816..4b52f38ff3130c 100644
--- a/homeassistant/components/bt_smarthub/device_tracker.py
+++ b/homeassistant/components/bt_smarthub/device_tracker.py
@@ -51,7 +51,7 @@ def _create_device(data):
return _Device(ip_address, mac, host, status, name)
-_Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"])
+_Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"]) # noqa: PYI024
class BTSmartHubScanner(DeviceScanner):
diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py
index 323f9eddd77c8b..3955fabdf00ce6 100644
--- a/homeassistant/components/button/__init__.py
+++ b/homeassistant/components/button/__init__.py
@@ -54,7 +54,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_PRESS,
- {},
+ None,
"_async_press_action",
)
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index d8fa4bfbc7a918..859ced1ba86b06 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -431,13 +431,13 @@ def unsub_track_time_interval(_event: Event) -> None:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval)
component.async_register_entity_service(
- SERVICE_ENABLE_MOTION, {}, "async_enable_motion_detection"
+ SERVICE_ENABLE_MOTION, None, "async_enable_motion_detection"
)
component.async_register_entity_service(
- SERVICE_DISABLE_MOTION, {}, "async_disable_motion_detection"
+ SERVICE_DISABLE_MOTION, None, "async_disable_motion_detection"
)
- component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
component.async_register_entity_service(
SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, async_handle_snapshot_service
)
@@ -862,7 +862,7 @@ async def handle(self, request: web.Request, camera: Camera) -> web.StreamRespon
# Compose camera stream from stills
interval = float(interval_str)
if interval < MIN_STREAM_INTERVAL:
- raise ValueError(f"Stream interval must be > {MIN_STREAM_INTERVAL}")
+ raise ValueError(f"Stream interval must be > {MIN_STREAM_INTERVAL}") # noqa: TRY301
return await camera.handle_async_still_stream(request, interval)
except ValueError as err:
raise web.HTTPBadRequest from err
@@ -992,7 +992,6 @@ async def async_handle_snapshot_service(
"""Handle snapshot services calls."""
hass = camera.hass
filename: Template = service_call.data[ATTR_FILENAME]
- filename.hass = hass
snapshot_file = filename.async_render(variables={ATTR_ENTITY_ID: camera})
@@ -1069,9 +1068,7 @@ async def async_handle_record_service(
if not stream:
raise HomeAssistantError(f"{camera.entity_id} does not support record service")
- hass = camera.hass
filename = service_call.data[CONF_FILENAME]
- filename.hass = hass
video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera})
await stream.async_record(
diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py
index b41dc9ddb41d70..e72eb196b61234 100644
--- a/homeassistant/components/cast/__init__.py
+++ b/homeassistant/components/cast/__init__.py
@@ -11,7 +11,7 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
@@ -19,7 +19,6 @@
from . import home_assistant_cast
from .const import DOMAIN
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [Platform.MEDIA_PLAYER]
diff --git a/homeassistant/components/chacon_dio/__init__.py b/homeassistant/components/chacon_dio/__init__.py
index 00558572fca2fa..94617cb3929c12 100644
--- a/homeassistant/components/chacon_dio/__init__.py
+++ b/homeassistant/components/chacon_dio/__init__.py
@@ -17,7 +17,7 @@
_LOGGER = logging.getLogger(__name__)
-PLATFORMS: list[Platform] = [Platform.COVER]
+PLATFORMS: list[Platform] = [Platform.COVER, Platform.SWITCH]
@dataclass
diff --git a/homeassistant/components/chacon_dio/manifest.json b/homeassistant/components/chacon_dio/manifest.json
index d077b130da9365..c0f4059e798ad8 100644
--- a/homeassistant/components/chacon_dio/manifest.json
+++ b/homeassistant/components/chacon_dio/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push",
"loggers": ["dio_chacon_api"],
- "requirements": ["dio-chacon-wifi-api==1.1.0"]
+ "requirements": ["dio-chacon-wifi-api==1.2.0"]
}
diff --git a/homeassistant/components/chacon_dio/switch.py b/homeassistant/components/chacon_dio/switch.py
new file mode 100644
index 00000000000000..be178c3c3b5349
--- /dev/null
+++ b/homeassistant/components/chacon_dio/switch.py
@@ -0,0 +1,74 @@
+"""Switch Platform for Chacon Dio REV-LIGHT and switch plug devices."""
+
+import logging
+from typing import Any
+
+from dio_chacon_wifi_api.const import DeviceTypeEnum
+
+from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import ChaconDioConfigEntry
+from .entity import ChaconDioEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ChaconDioConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Chacon Dio switch devices."""
+ data = config_entry.runtime_data
+ client = data.client
+
+ async_add_entities(
+ ChaconDioSwitch(client, device)
+ for device in data.list_devices
+ if device["type"]
+ in (DeviceTypeEnum.SWITCH_LIGHT.value, DeviceTypeEnum.SWITCH_PLUG.value)
+ )
+
+
+class ChaconDioSwitch(ChaconDioEntity, SwitchEntity):
+ """Object for controlling a Chacon Dio switch."""
+
+ _attr_device_class = SwitchDeviceClass.SWITCH
+ _attr_name = None
+
+ def _update_attr(self, data: dict[str, Any]) -> None:
+ """Recomputes the attributes values either at init or when the device state changes."""
+ self._attr_available = data["connected"]
+ self._attr_is_on = data["is_on"]
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch.
+
+ Turned on status is effective after the server callback that triggers callback_device_state.
+ """
+
+ _LOGGER.debug(
+ "Turn on the switch %s , %s, %s",
+ self.target_id,
+ self.entity_id,
+ self._attr_is_on,
+ )
+
+ await self.client.switch_switch(self.target_id, True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch.
+
+ Turned on status is effective after the server callback that triggers callback_device_state.
+ """
+
+ _LOGGER.debug(
+ "Turn off the switch %s , %s, %s",
+ self.target_id,
+ self.entity_id,
+ self._attr_is_on,
+ )
+
+ await self.client.switch_switch(self.target_id, False)
diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py
index 07ed8ce7d6697f..f6de35a4156c55 100644
--- a/homeassistant/components/channels/media_player.py
+++ b/homeassistant/components/channels/media_player.py
@@ -49,12 +49,12 @@ async def async_setup_platform(
platform.async_register_entity_service(
SERVICE_SEEK_FORWARD,
- {},
+ None,
"seek_forward",
)
platform.async_register_entity_service(
SERVICE_SEEK_BACKWARD,
- {},
+ None,
"seek_backward",
)
platform.async_register_entity_service(
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index 94cba54b24750d..6097e4f1346129 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -156,19 +156,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_TURN_ON,
- {},
+ None,
"async_turn_on",
[ClimateEntityFeature.TURN_ON],
)
component.async_register_entity_service(
SERVICE_TURN_OFF,
- {},
+ None,
"async_turn_off",
[ClimateEntityFeature.TURN_OFF],
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.TURN_ON],
)
@@ -914,12 +914,37 @@ async def async_service_temperature_set(
"""Handle set temperature service."""
hass = entity.hass
kwargs = {}
+ min_temp = entity.min_temp
+ max_temp = entity.max_temp
+ temp_unit = entity.temperature_unit
for value, temp in service_call.data.items():
if value in CONVERTIBLE_ATTRIBUTE:
- kwargs[value] = TemperatureConverter.convert(
- temp, hass.config.units.temperature_unit, entity.temperature_unit
+ kwargs[value] = check_temp = TemperatureConverter.convert(
+ temp, hass.config.units.temperature_unit, temp_unit
)
+
+ _LOGGER.debug(
+ "Check valid temperature %d %s (%d %s) in range %d %s - %d %s",
+ check_temp,
+ entity.temperature_unit,
+ temp,
+ hass.config.units.temperature_unit,
+ min_temp,
+ temp_unit,
+ max_temp,
+ temp_unit,
+ )
+ if check_temp < min_temp or check_temp > max_temp:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="temp_out_of_range",
+ translation_placeholders={
+ "check_temp": str(check_temp),
+ "min_temp": str(min_temp),
+ "max_temp": str(max_temp),
+ },
+ )
else:
kwargs[value] = temp
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index dc2124418242ce..1af21815b9f35d 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -266,6 +266,9 @@
},
"not_valid_fan_mode": {
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
+ },
+ "temp_out_of_range": {
+ "message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}."
}
}
}
diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py
index 5934e43f8a2194..bd27be71d18ee1 100644
--- a/homeassistant/components/cloudflare/__init__.py
+++ b/homeassistant/components/cloudflare/__init__.py
@@ -18,7 +18,6 @@
HomeAssistantError,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.network import is_ipv4_address
@@ -27,8 +26,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Cloudflare from a config entry."""
diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py
index 0a34168b4eebd2..f5fd8fa1dc3ab8 100644
--- a/homeassistant/components/coinbase/__init__.py
+++ b/homeassistant/components/coinbase/__init__.py
@@ -5,18 +5,35 @@
from datetime import timedelta
import logging
-from coinbase.wallet.client import Client
+from coinbase.rest import RESTClient
+from coinbase.rest.rest_base import HTTPError
+from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import entity_registry as er
from homeassistant.util import Throttle
from .const import (
+ ACCOUNT_IS_VAULT,
+ API_ACCOUNT_AMOUNT,
+ API_ACCOUNT_AVALIABLE,
+ API_ACCOUNT_BALANCE,
+ API_ACCOUNT_CURRENCY,
+ API_ACCOUNT_CURRENCY_CODE,
+ API_ACCOUNT_HOLD,
API_ACCOUNT_ID,
- API_ACCOUNTS_DATA,
+ API_ACCOUNT_NAME,
+ API_ACCOUNT_VALUE,
+ API_ACCOUNTS,
+ API_DATA,
+ API_RATES_CURRENCY,
+ API_RESOURCE_TYPE,
+ API_TYPE_VAULT,
+ API_V3_ACCOUNT_ID,
+ API_V3_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
@@ -29,9 +46,6 @@
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Coinbase from a config entry."""
@@ -59,9 +73,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance."""
- client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
+ if "organizations" not in entry.data[CONF_API_KEY]:
+ client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
+ version = "v2"
+ else:
+ client = RESTClient(
+ api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
+ )
+ version = "v3"
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
- instance = CoinbaseData(client, base_rate)
+ instance = CoinbaseData(client, base_rate, version)
instance.update()
return instance
@@ -86,42 +107,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
registry.async_remove(entity.entity_id)
-def get_accounts(client):
+def get_accounts(client, version):
"""Handle paginated accounts."""
response = client.get_accounts()
- accounts = response[API_ACCOUNTS_DATA]
- next_starting_after = response.pagination.next_starting_after
-
- while next_starting_after:
- response = client.get_accounts(starting_after=next_starting_after)
- accounts += response[API_ACCOUNTS_DATA]
+ if version == "v2":
+ accounts = response[API_DATA]
next_starting_after = response.pagination.next_starting_after
- return accounts
+ while next_starting_after:
+ response = client.get_accounts(starting_after=next_starting_after)
+ accounts += response[API_DATA]
+ next_starting_after = response.pagination.next_starting_after
+
+ return [
+ {
+ API_ACCOUNT_ID: account[API_ACCOUNT_ID],
+ API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
+ API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
+ API_ACCOUNT_CURRENCY_CODE
+ ],
+ API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
+ ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
+ }
+ for account in accounts
+ ]
+
+ accounts = response[API_ACCOUNTS]
+ while response["has_next"]:
+ response = client.get_accounts(cursor=response["cursor"])
+ accounts += response["accounts"]
+
+ return [
+ {
+ API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
+ API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
+ API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
+ API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
+ + account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
+ ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
+ }
+ for account in accounts
+ ]
class CoinbaseData:
"""Get the latest data and update the states."""
- def __init__(self, client, exchange_base):
+ def __init__(self, client, exchange_base, version):
"""Init the coinbase data object."""
self.client = client
self.accounts = None
self.exchange_base = exchange_base
self.exchange_rates = None
- self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
+ if version == "v2":
+ self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
+ else:
+ self.user_id = (
+ "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
+ )
+ self.api_version = version
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from coinbase."""
try:
- self.accounts = get_accounts(self.client)
- self.exchange_rates = self.client.get_exchange_rates(
- currency=self.exchange_base
- )
- except AuthenticationError as coinbase_error:
+ self.accounts = get_accounts(self.client, self.api_version)
+ if self.api_version == "v2":
+ self.exchange_rates = self.client.get_exchange_rates(
+ currency=self.exchange_base
+ )
+ else:
+ self.exchange_rates = self.client.get(
+ "/v2/exchange-rates",
+ params={API_RATES_CURRENCY: self.exchange_base},
+ )[API_DATA]
+ except (AuthenticationError, HTTPError) as coinbase_error:
_LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error
)
diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py
index 623d5cf67313b3..616fdaf8f7ab43 100644
--- a/homeassistant/components/coinbase/config_flow.py
+++ b/homeassistant/components/coinbase/config_flow.py
@@ -5,7 +5,9 @@
import logging
from typing import Any
-from coinbase.wallet.client import Client
+from coinbase.rest import RESTClient
+from coinbase.rest.rest_base import HTTPError
+from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
import voluptuous as vol
@@ -15,18 +17,17 @@
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
+from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from . import get_accounts
from .const import (
+ ACCOUNT_IS_VAULT,
API_ACCOUNT_CURRENCY,
- API_ACCOUNT_CURRENCY_CODE,
+ API_DATA,
API_RATES,
- API_RESOURCE_TYPE,
- API_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_PRECISION,
@@ -49,8 +50,11 @@
def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials."""
- client = Client(api_key, api_token)
- return client.get_current_user()
+ if "organizations" not in api_key:
+ client = LegacyClient(api_key, api_token)
+ return client.get_current_user()["name"]
+ client = RESTClient(api_key=api_key, api_secret=api_token)
+ return client.get_portfolios()["portfolios"][0]["name"]
async def validate_api(hass: HomeAssistant, data):
@@ -60,11 +64,13 @@ async def validate_api(hass: HomeAssistant, data):
user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
)
- except AuthenticationError as error:
- if "api key" in str(error):
+ except (AuthenticationError, HTTPError) as error:
+ if "api key" in str(error) or " 401 Client Error" in str(error):
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error
- if "invalid signature" in str(error):
+ if "invalid signature" in str(
+ error
+ ) or "'Could not deserialize key data" in str(error):
_LOGGER.debug(
"Coinbase rejected API credentials due to an invalid API secret"
)
@@ -73,8 +79,8 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error
except ConnectionError as error:
raise CannotConnect from error
-
- return {"title": user["name"]}
+ api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
+ return {"title": user, "api_version": api_version}
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
@@ -82,14 +88,20 @@ async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, optio
client = hass.data[DOMAIN][config_entry.entry_id].client
- accounts = await hass.async_add_executor_job(get_accounts, client)
+ accounts = await hass.async_add_executor_job(
+ get_accounts, client, config_entry.data.get("api_version", "v2")
+ )
accounts_currencies = [
- account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
+ account[API_ACCOUNT_CURRENCY]
for account in accounts
- if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
+ if not account[ACCOUNT_IS_VAULT]
]
- available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
+ if config_entry.data.get("api_version", "v2") == "v2":
+ available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
+ else:
+ resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
+ available_rates = resp[API_DATA]
if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies:
@@ -134,6 +146,7 @@ async def async_step_user(
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
+ user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py
index f5c75e3f9267ba..0f47d4bc208657 100644
--- a/homeassistant/components/coinbase/const.py
+++ b/homeassistant/components/coinbase/const.py
@@ -1,5 +1,7 @@
"""Constants used for Coinbase."""
+ACCOUNT_IS_VAULT = "is_vault"
+
CONF_CURRENCIES = "account_balance_currencies"
CONF_EXCHANGE_BASE = "exchange_base"
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
@@ -10,18 +12,25 @@
# Constants for data returned by Coinbase API
API_ACCOUNT_AMOUNT = "amount"
+API_ACCOUNT_AVALIABLE = "available_balance"
API_ACCOUNT_BALANCE = "balance"
API_ACCOUNT_CURRENCY = "currency"
API_ACCOUNT_CURRENCY_CODE = "code"
+API_ACCOUNT_HOLD = "hold"
API_ACCOUNT_ID = "id"
API_ACCOUNT_NATIVE_BALANCE = "balance"
API_ACCOUNT_NAME = "name"
-API_ACCOUNTS_DATA = "data"
+API_ACCOUNT_VALUE = "value"
+API_ACCOUNTS = "accounts"
+API_DATA = "data"
API_RATES = "rates"
+API_RATES_CURRENCY = "currency"
API_RESOURCE_PATH = "resource_path"
API_RESOURCE_TYPE = "type"
API_TYPE_VAULT = "vault"
API_USD = "USD"
+API_V3_ACCOUNT_ID = "uuid"
+API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
WALLETS = {
"1INCH": "1INCH",
diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json
index 515fe9f9abb742..be632b5e856027 100644
--- a/homeassistant/components/coinbase/manifest.json
+++ b/homeassistant/components/coinbase/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling",
"loggers": ["coinbase"],
- "requirements": ["coinbase==2.1.0"]
+ "requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
}
diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py
index 83c63fa55fbd59..d3f3c81fb0c387 100644
--- a/homeassistant/components/coinbase/sensor.py
+++ b/homeassistant/components/coinbase/sensor.py
@@ -12,15 +12,12 @@
from . import CoinbaseData
from .const import (
+ ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
- API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
- API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_ID,
API_ACCOUNT_NAME,
API_RATES,
- API_RESOURCE_TYPE,
- API_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_PRECISION,
CONF_EXCHANGE_PRECISION_DEFAULT,
@@ -31,6 +28,7 @@
_LOGGER = logging.getLogger(__name__)
ATTR_NATIVE_BALANCE = "Balance in native currency"
+ATTR_API_VERSION = "API Version"
CURRENCY_ICONS = {
"BTC": "mdi:currency-btc",
@@ -56,9 +54,9 @@ async def async_setup_entry(
entities: list[SensorEntity] = []
provided_currencies: list[str] = [
- account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
+ account[API_ACCOUNT_CURRENCY]
for account in instance.accounts
- if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
+ if not account[ACCOUNT_IS_VAULT]
]
desired_currencies: list[str] = []
@@ -73,6 +71,11 @@ async def async_setup_entry(
)
for currency in desired_currencies:
+ _LOGGER.debug(
+ "Attempting to set up %s account sensor with %s API",
+ currency,
+ instance.api_version,
+ )
if currency not in provided_currencies:
_LOGGER.warning(
(
@@ -85,12 +88,17 @@ async def async_setup_entry(
entities.append(AccountSensor(instance, currency))
if CONF_EXCHANGE_RATES in config_entry.options:
- entities.extend(
- ExchangeRateSensor(
- instance, rate, exchange_base_currency, exchange_precision
+ for rate in config_entry.options[CONF_EXCHANGE_RATES]:
+ _LOGGER.debug(
+ "Attempting to set up %s account sensor with %s API",
+ rate,
+ instance.api_version,
+ )
+ entities.append(
+ ExchangeRateSensor(
+ instance, rate, exchange_base_currency, exchange_precision
+ )
)
- for rate in config_entry.options[CONF_EXCHANGE_RATES]
- )
async_add_entities(entities)
@@ -105,26 +113,21 @@ def __init__(self, coinbase_data: CoinbaseData, currency: str) -> None:
self._coinbase_data = coinbase_data
self._currency = currency
for account in coinbase_data.accounts:
- if (
- account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
- or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
- ):
+ if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
continue
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
self._attr_unique_id = (
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
- f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
+ f"{account[API_ACCOUNT_CURRENCY]}"
)
- self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
- self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
- API_ACCOUNT_CURRENCY_CODE
- ]
+ self._attr_native_value = account[API_ACCOUNT_AMOUNT]
+ self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
self._attr_icon = CURRENCY_ICONS.get(
- account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
+ account[API_ACCOUNT_CURRENCY],
DEFAULT_COIN_ICON,
)
self._native_balance = round(
- float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
+ float(account[API_ACCOUNT_AMOUNT])
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
2,
)
@@ -144,21 +147,26 @@ def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes of the sensor."""
return {
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
+ ATTR_API_VERSION: self._coinbase_data.api_version,
}
def update(self) -> None:
"""Get the latest state of the sensor."""
+ _LOGGER.debug(
+ "Updating %s account sensor with %s API",
+ self._currency,
+ self._coinbase_data.api_version,
+ )
self._coinbase_data.update()
for account in self._coinbase_data.accounts:
if (
- account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
- != self._currency
- or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
+ account[API_ACCOUNT_CURRENCY] != self._currency
+ or account[ACCOUNT_IS_VAULT]
):
continue
- self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
+ self._attr_native_value = account[API_ACCOUNT_AMOUNT]
self._native_balance = round(
- float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
+ float(account[API_ACCOUNT_AMOUNT])
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
2,
)
@@ -202,8 +210,13 @@ def __init__(
def update(self) -> None:
"""Get the latest state of the sensor."""
+ _LOGGER.debug(
+ "Updating %s rate sensor with %s API",
+ self._currency,
+ self._coinbase_data.api_version,
+ )
self._coinbase_data.update()
self._attr_native_value = round(
- 1 / float(self._coinbase_data.exchange_rates.rates[self._currency]),
+ 1 / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
self._precision,
)
diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py
index 2ff17e86efde2a..f5d9ad9d63dd8b 100644
--- a/homeassistant/components/command_line/binary_sensor.py
+++ b/homeassistant/components/command_line/binary_sensor.py
@@ -40,6 +40,8 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Command line Binary Sensor."""
+ if not discovery_info:
+ return
discovery_info = cast(DiscoveryInfoType, discovery_info)
binary_sensor_config = discovery_info
@@ -51,9 +53,7 @@ async def async_setup_platform(
scan_interval: timedelta = binary_sensor_config.get(
CONF_SCAN_INTERVAL, SCAN_INTERVAL
)
-
- if value_template := binary_sensor_config.get(CONF_VALUE_TEMPLATE):
- value_template.hass = hass
+ value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE)
data = CommandSensorData(hass, command, command_timeout)
diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py
index 2c6ec78b689b9b..8ddfd399ba8b67 100644
--- a/homeassistant/components/command_line/cover.py
+++ b/homeassistant/components/command_line/cover.py
@@ -4,7 +4,7 @@
import asyncio
from datetime import datetime, timedelta
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.cover import CoverEntity
from homeassistant.const import (
@@ -37,6 +37,8 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up cover controlled by shell commands."""
+ if not discovery_info:
+ return
covers = []
discovery_info = cast(DiscoveryInfoType, discovery_info)
@@ -45,9 +47,6 @@ async def async_setup_platform(
}
for device_name, cover_config in entities.items():
- if value_template := cover_config.get(CONF_VALUE_TEMPLATE):
- value_template.hass = hass
-
trigger_entity_config = {
CONF_NAME: Template(cover_config.get(CONF_NAME, device_name), hass),
**{k: v for k, v in cover_config.items() if k in TRIGGER_ENTITY_OPTIONS},
@@ -60,7 +59,7 @@ async def async_setup_platform(
cover_config[CONF_COMMAND_CLOSE],
cover_config[CONF_COMMAND_STOP],
cover_config.get(CONF_COMMAND_STATE),
- value_template,
+ cover_config.get(CONF_VALUE_TEMPLATE),
cover_config[CONF_COMMAND_TIMEOUT],
cover_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
)
@@ -113,7 +112,7 @@ async def async_added_to_hass(self) -> None:
async def _async_move_cover(self, command: str) -> bool:
"""Execute the actual commands."""
- LOGGER.info("Running command: %s", command)
+ LOGGER.debug("Running command: %s", command)
returncode = await async_call_shell_with_timeout(command, self._timeout)
success = returncode == 0
@@ -142,10 +141,10 @@ def current_cover_position(self) -> int | None:
async def _async_query_state(self) -> str | None:
"""Query for the state."""
- if self._command_state:
- LOGGER.info("Running state value command: %s", self._command_state)
- return await async_check_output_or_log(self._command_state, self._timeout)
- return None
+ if TYPE_CHECKING:
+ assert self._command_state
+ LOGGER.debug("Running state value command: %s", self._command_state)
+ return await async_check_output_or_log(self._command_state, self._timeout)
async def _update_entity_state(self, now: datetime | None = None) -> None:
"""Update the state of the entity."""
diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py
index 14245b72288817..4f5a4e4b499606 100644
--- a/homeassistant/components/command_line/notify.py
+++ b/homeassistant/components/command_line/notify.py
@@ -21,8 +21,10 @@ def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
-) -> CommandLineNotificationService:
+) -> CommandLineNotificationService | None:
"""Get the Command Line notification service."""
+ if not discovery_info:
+ return None
discovery_info = cast(DiscoveryInfoType, discovery_info)
notify_config = discovery_info
diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py
index 14edbb55ed0b33..7c31af165f995d 100644
--- a/homeassistant/components/command_line/sensor.py
+++ b/homeassistant/components/command_line/sensor.py
@@ -48,6 +48,8 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Command Sensor."""
+ if not discovery_info:
+ return
discovery_info = cast(DiscoveryInfoType, discovery_info)
sensor_config = discovery_info
@@ -57,11 +59,9 @@ async def async_setup_platform(
json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES)
json_attributes_path: str | None = sensor_config.get(CONF_JSON_ATTRIBUTES_PATH)
scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE)
data = CommandSensorData(hass, command, command_timeout)
- if value_template := sensor_config.get(CONF_VALUE_TEMPLATE):
- value_template.hass = hass
-
trigger_entity_config = {
CONF_NAME: Template(sensor_config[CONF_NAME], hass),
**{k: v for k, v in sensor_config.items() if k in TRIGGER_ENTITY_OPTIONS},
diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py
index f8e9d21cf23f3a..e42c2226cf2b46 100644
--- a/homeassistant/components/command_line/switch.py
+++ b/homeassistant/components/command_line/switch.py
@@ -4,7 +4,7 @@
import asyncio
from datetime import datetime, timedelta
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import (
@@ -36,6 +36,8 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Find and return switches controlled by shell commands."""
+ if not discovery_info:
+ return
switches = []
discovery_info = cast(DiscoveryInfoType, discovery_info)
@@ -44,9 +46,6 @@ async def async_setup_platform(
}
for object_id, switch_config in entities.items():
- if value_template := switch_config.get(CONF_VALUE_TEMPLATE):
- value_template.hass = hass
-
trigger_entity_config = {
CONF_NAME: Template(switch_config.get(CONF_NAME, object_id), hass),
**{k: v for k, v in switch_config.items() if k in TRIGGER_ENTITY_OPTIONS},
@@ -59,7 +58,7 @@ async def async_setup_platform(
switch_config[CONF_COMMAND_ON],
switch_config[CONF_COMMAND_OFF],
switch_config.get(CONF_COMMAND_STATE),
- value_template,
+ switch_config.get(CONF_VALUE_TEMPLATE),
switch_config[CONF_COMMAND_TIMEOUT],
switch_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
)
@@ -112,7 +111,7 @@ async def async_added_to_hass(self) -> None:
async def _switch(self, command: str) -> bool:
"""Execute the actual commands."""
- LOGGER.info("Running command: %s", command)
+ LOGGER.debug("Running command: %s", command)
success = await async_call_shell_with_timeout(command, self._timeout) == 0
@@ -123,12 +122,12 @@ async def _switch(self, command: str) -> bool:
async def _async_query_state_value(self, command: str) -> str | None:
"""Execute state command for return value."""
- LOGGER.info("Running state value command: %s", command)
+ LOGGER.debug("Running state value command: %s", command)
return await async_check_output_or_log(command, self._timeout)
async def _async_query_state_code(self, command: str) -> bool:
"""Execute state command for return code."""
- LOGGER.info("Running state code command: %s", command)
+ LOGGER.debug("Running state code command: %s", command)
return (
await async_call_shell_with_timeout(
command, self._timeout, log_return_code=False
@@ -143,11 +142,11 @@ def assumed_state(self) -> bool:
async def _async_query_state(self) -> str | int | None:
"""Query for state."""
- if self._command_state:
- if self._value_template:
- return await self._async_query_state_value(self._command_state)
- return await self._async_query_state_code(self._command_state)
- return None
+ if TYPE_CHECKING:
+ assert self._command_state
+ if self._value_template:
+ return await self._async_query_state_value(self._command_state)
+ return await self._async_query_state_code(self._command_state)
async def _update_entity_state(self, now: datetime | None = None) -> None:
"""Update the state of the entity."""
diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py
index c9a6eab5c625eb..a3d0cebd1fc72d 100644
--- a/homeassistant/components/control4/__init__.py
+++ b/homeassistant/components/control4/__init__.py
@@ -50,7 +50,8 @@
async def call_c4_api_retry(func, *func_args):
"""Call C4 API function and retry on failure."""
- for i in range(API_RETRY_TIMES):
+ # Ruff doesn't understand this loop - the exception is always raised after the retries
+ for i in range(API_RETRY_TIMES): # noqa: RET503
try:
return await func(*func_args)
except client_exceptions.ClientError as exception:
diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py
index f6d746c9cb48f6..f6eb410cbf226c 100644
--- a/homeassistant/components/control4/config_flow.py
+++ b/homeassistant/components/control4/config_flow.py
@@ -105,9 +105,9 @@ async def async_step_user(self, user_input=None):
)
try:
if not await hub.authenticate():
- raise InvalidAuth
+ raise InvalidAuth # noqa: TRY301
if not await hub.connect_to_director():
- raise CannotConnect
+ raise CannotConnect # noqa: TRY301
except InvalidAuth:
errors["base"] = "invalid_auth"
except CannotConnect:
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 45393289ac88c0..1661d2ad30de7d 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -47,6 +47,7 @@
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
+from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
@@ -348,6 +349,16 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu
}
for entity in result.entities_list
}
+ async_conversation_trace_append(
+ ConversationTraceEventType.TOOL_CALL,
+ {
+ "intent_name": result.intent.name,
+ "slots": {
+ entity.name: entity.value or entity.text
+ for entity in result.entities_list
+ },
+ },
+ )
try:
intent_response = await intent.async_handle(
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 65c79cef187e0b..d7a308b8b2bbfb 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.29"]
+ "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.7"]
}
diff --git a/homeassistant/components/conversation/trace.py b/homeassistant/components/conversation/trace.py
index 08b271d9058098..6f993aa326a92c 100644
--- a/homeassistant/components/conversation/trace.py
+++ b/homeassistant/components/conversation/trace.py
@@ -22,8 +22,8 @@ class ConversationTraceEventType(enum.StrEnum):
AGENT_DETAIL = "agent_detail"
"""Event detail added by a conversation agent."""
- LLM_TOOL_CALL = "llm_tool_call"
- """An LLM Tool call"""
+ TOOL_CALL = "tool_call"
+ """A conversation agent Tool call or default agent intent call."""
@dataclass(frozen=True)
diff --git a/homeassistant/components/coolmaster/manifest.json b/homeassistant/components/coolmaster/manifest.json
index b405a82ad62794..8775d7f72b86d8 100644
--- a/homeassistant/components/coolmaster/manifest.json
+++ b/homeassistant/components/coolmaster/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
- "requirements": ["pycoolmasternet-async==0.2.0"]
+ "requirements": ["pycoolmasternet-async==0.2.2"]
}
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
index 324668a63e240a..f0a14aa79510e1 100644
--- a/homeassistant/components/counter/__init__.py
+++ b/homeassistant/components/counter/__init__.py
@@ -122,9 +122,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS
).async_setup(hass)
- component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment")
- component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement")
- component.async_register_entity_service(SERVICE_RESET, {}, "async_reset")
+ component.async_register_entity_service(SERVICE_INCREMENT, None, "async_increment")
+ component.async_register_entity_service(SERVICE_DECREMENT, None, "async_decrement")
+ component.async_register_entity_service(SERVICE_RESET, None, "async_reset")
component.async_register_entity_service(
SERVICE_SET_VALUE,
{vol.Required(VALUE): cv.positive_int},
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index 645bd88de7ac75..90d2b644810fa5 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -158,11 +158,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config)
component.async_register_entity_service(
- SERVICE_OPEN_COVER, {}, "async_open_cover", [CoverEntityFeature.OPEN]
+ SERVICE_OPEN_COVER, None, "async_open_cover", [CoverEntityFeature.OPEN]
)
component.async_register_entity_service(
- SERVICE_CLOSE_COVER, {}, "async_close_cover", [CoverEntityFeature.CLOSE]
+ SERVICE_CLOSE_COVER, None, "async_close_cover", [CoverEntityFeature.CLOSE]
)
component.async_register_entity_service(
@@ -177,33 +177,33 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
component.async_register_entity_service(
- SERVICE_STOP_COVER, {}, "async_stop_cover", [CoverEntityFeature.STOP]
+ SERVICE_STOP_COVER, None, "async_stop_cover", [CoverEntityFeature.STOP]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE],
)
component.async_register_entity_service(
SERVICE_OPEN_COVER_TILT,
- {},
+ None,
"async_open_cover_tilt",
[CoverEntityFeature.OPEN_TILT],
)
component.async_register_entity_service(
SERVICE_CLOSE_COVER_TILT,
- {},
+ None,
"async_close_cover_tilt",
[CoverEntityFeature.CLOSE_TILT],
)
component.async_register_entity_service(
SERVICE_STOP_COVER_TILT,
- {},
+ None,
"async_stop_cover_tilt",
[CoverEntityFeature.STOP_TILT],
)
@@ -221,7 +221,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_TOGGLE_COVER_TILT,
- {},
+ None,
"async_toggle_tilt",
[CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT],
)
diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py
index b38f698ac3d7e7..7580cff063a17b 100644
--- a/homeassistant/components/cover/intent.py
+++ b/homeassistant/components/cover/intent.py
@@ -4,7 +4,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
-from . import DOMAIN
+from . import DOMAIN, CoverDeviceClass
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
@@ -21,6 +21,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
"Opening {}",
description="Opens a cover",
platforms={DOMAIN},
+ device_classes={CoverDeviceClass},
),
)
intent.async_register(
@@ -32,5 +33,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
"Closing {}",
description="Closes a cover",
platforms={DOMAIN},
+ device_classes={CoverDeviceClass},
),
)
diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py
index 2ad0f88a2ab930..01dec10efe016c 100644
--- a/homeassistant/components/currencylayer/sensor.py
+++ b/homeassistant/components/currencylayer/sensor.py
@@ -108,7 +108,7 @@ def update(self):
try:
result = requests.get(self._resource, params=self._parameters, timeout=10)
if "error" in result.json():
- raise ValueError(result.json()["error"]["info"])
+ raise ValueError(result.json()["error"]["info"]) # noqa: TRY301
self.data = result.json()["quotes"]
_LOGGER.debug("Currencylayer data updated: %s", result.json()["timestamp"])
except ValueError as err:
diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py
index 1bd833f354dd5d..4da6bcee50b4d2 100644
--- a/homeassistant/components/daikin/__init__.py
+++ b/homeassistant/components/daikin/__init__.py
@@ -23,7 +23,6 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.util import Throttle
@@ -36,8 +35,6 @@
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Establish connection with Daikin."""
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index 827deb27add5ab..0d93c0e25ad279 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
- "requirements": ["pydaikin==2.13.1"],
+ "requirements": ["pydaikin==2.13.4"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
index 371b783b653391..d088dfb140b6a9 100644
--- a/homeassistant/components/demo/__init__.py
+++ b/homeassistant/components/demo/__init__.py
@@ -55,7 +55,6 @@
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.TTS,
- Platform.MAILBOX,
Platform.IMAGE_PROCESSING,
Platform.DEVICE_TRACKER,
]
diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py
index f95042f2cc7295..d1b558842b6bd8 100644
--- a/homeassistant/components/demo/alarm_control_panel.py
+++ b/homeassistant/components/demo/alarm_control_panel.py
@@ -30,9 +30,10 @@ async def async_setup_entry(
"""Set up the Demo config entry."""
async_add_entities(
[
- DemoAlarm( # type:ignore[no-untyped-call]
+ ManualAlarm( # type:ignore[no-untyped-call]
hass,
"Security",
+ "demo_alarm_control_panel",
"1234",
None,
True,
@@ -74,9 +75,3 @@ async def async_setup_entry(
)
]
)
-
-
-class DemoAlarm(ManualAlarm):
- """Demo Alarm Control Panel."""
-
- _attr_unique_id = "demo_alarm_control_panel"
diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py
deleted file mode 100644
index e0cdd05782dfb3..00000000000000
--- a/homeassistant/components/demo/mailbox.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""Support for a demo mailbox."""
-
-from __future__ import annotations
-
-from hashlib import sha1
-import logging
-import os
-from typing import Any
-
-from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import dt as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-MAILBOX_NAME = "DemoMailbox"
-
-
-async def async_get_handler(
- hass: HomeAssistant,
- config: ConfigType,
- discovery_info: DiscoveryInfoType | None = None,
-) -> Mailbox:
- """Set up the Demo mailbox."""
- return DemoMailbox(hass, MAILBOX_NAME)
-
-
-class DemoMailbox(Mailbox):
- """Demo Mailbox."""
-
- def __init__(self, hass: HomeAssistant, name: str) -> None:
- """Initialize Demo mailbox."""
- super().__init__(hass, name)
- self._messages: dict[str, dict[str, Any]] = {}
- txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
- for idx in range(10):
- msgtime = int(
- dt_util.as_timestamp(dt_util.utcnow()) - 3600 * 24 * (10 - idx)
- )
- msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}"
- msgsha = sha1(msgtxt.encode("utf-8")).hexdigest()
- msg = {
- "info": {
- "origtime": msgtime,
- "callerid": "John Doe <212-555-1212>",
- "duration": "10",
- },
- "text": msgtxt,
- "sha": msgsha,
- }
- self._messages[msgsha] = msg
-
- @property
- def media_type(self) -> str:
- """Return the supported media type."""
- return CONTENT_TYPE_MPEG
-
- @property
- def can_delete(self) -> bool:
- """Return if messages can be deleted."""
- return True
-
- @property
- def has_media(self) -> bool:
- """Return if messages have attached media files."""
- return True
-
- def _get_media(self) -> bytes:
- """Return the media blob for the msgid."""
- audio_path = os.path.join(os.path.dirname(__file__), "tts.mp3")
- with open(audio_path, "rb") as file:
- return file.read()
-
- async def async_get_media(self, msgid: str) -> bytes:
- """Return the media blob for the msgid."""
- if msgid not in self._messages:
- raise StreamError("Message not found")
- return await self.hass.async_add_executor_job(self._get_media)
-
- async def async_get_messages(self) -> list[dict[str, Any]]:
- """Return a list of the current messages."""
- return sorted(
- self._messages.values(),
- key=lambda item: item["info"]["origtime"],
- reverse=True,
- )
-
- async def async_delete(self, msgid: str) -> bool:
- """Delete the specified messages."""
- if msgid in self._messages:
- _LOGGER.info("Deleting: %s", msgid)
- del self._messages[msgid]
- self.async_update()
- return True
diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py
index 8d6df72a67eb04..a7d8565d6a4cd1 100644
--- a/homeassistant/components/denonavr/media_player.py
+++ b/homeassistant/components/denonavr/media_player.py
@@ -152,7 +152,7 @@ async def async_setup_entry(
)
platform.async_register_entity_service(
SERVICE_UPDATE_AUDYSSEY,
- {},
+ None,
f"async_{SERVICE_UPDATE_AUDYSSEY}",
)
diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py
index ac168c06fb164f..15cb67f5ee8d29 100644
--- a/homeassistant/components/device_tracker/legacy.py
+++ b/homeassistant/components/device_tracker/legacy.py
@@ -350,7 +350,7 @@ async def async_setup_legacy(
discovery_info,
)
else:
- raise HomeAssistantError("Invalid legacy device_tracker platform.")
+ raise HomeAssistantError("Invalid legacy device_tracker platform.") # noqa: TRY301
if scanner is not None:
async_setup_scanner_platform(
diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py
index 50eb6bc7959a82..e59fa4e9d0d4bc 100644
--- a/homeassistant/components/directv/__init__.py
+++ b/homeassistant/components/directv/__init__.py
@@ -10,13 +10,10 @@
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
SCAN_INTERVAL = timedelta(seconds=30)
diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py
index 7ffb6655bb6ca8..acd9d7fe71b216 100644
--- a/homeassistant/components/doods/image_processing.py
+++ b/homeassistant/components/doods/image_processing.py
@@ -207,8 +207,6 @@ def __init__(self, hass, camera_entity, name, doods, detector, config):
]
self._covers = area_config[CONF_COVERS]
- template.attach(hass, self._file_out)
-
self._dconfig = dconfig
self._matches = {}
self._total_matches = 0
diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py
index 8989e0ec0be1cb..113b8031d9bea8 100644
--- a/homeassistant/components/doorbird/__init__.py
+++ b/homeassistant/components/doorbird/__init__.py
@@ -3,11 +3,11 @@
from __future__ import annotations
from http import HTTPStatus
+import logging
from aiohttp import ClientResponseError
from doorbirdpy import DoorBird
-from homeassistant.components import persistent_notification
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
@@ -17,6 +17,7 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -30,6 +31,8 @@
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the DoorBird component."""
@@ -68,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
door_bird_data = DoorBirdData(door_station, info, event_entity_ids)
door_station.update_events(events)
# Subscribe to doorbell or motion events
- if not await _async_register_events(hass, door_station):
+ if not await _async_register_events(hass, door_station, entry):
raise ConfigEntryNotReady
entry.async_on_unload(entry.add_update_listener(_update_listener))
@@ -84,24 +87,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
async def _async_register_events(
- hass: HomeAssistant, door_station: ConfiguredDoorBird
+ hass: HomeAssistant, door_station: ConfiguredDoorBird, entry: DoorBirdConfigEntry
) -> bool:
"""Register events on device."""
+ issue_id = f"doorbird_schedule_error_{entry.entry_id}"
try:
await door_station.async_register_events()
- except ClientResponseError:
- persistent_notification.async_create(
+ except ClientResponseError as ex:
+ ir.async_create_issue(
hass,
- (
- "Doorbird configuration failed. Please verify that API "
- "Operator permission is enabled for the Doorbird user. "
- "A restart will be required once permissions have been "
- "verified."
- ),
- title="Doorbird Configuration Failure",
- notification_id="doorbird_schedule_error",
+ DOMAIN,
+ issue_id,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="error_registering_events",
+ data={"entry_id": entry.entry_id},
+ is_fixable=True,
+ translation_placeholders={
+ "error": str(ex),
+ "name": door_station.name or entry.data[CONF_NAME],
+ },
)
+ _LOGGER.debug("Error registering DoorBird events", exc_info=True)
return False
+ else:
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
return True
@@ -111,4 +120,4 @@ async def _update_listener(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> N
door_station = entry.runtime_data.door_station
door_station.update_events(entry.options[CONF_EVENTS])
# Subscribe to doorbell or motion events
- await _async_register_events(hass, door_station)
+ await _async_register_events(hass, door_station, entry)
diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py
index 866251f3d282b1..adcb441f458410 100644
--- a/homeassistant/components/doorbird/device.py
+++ b/homeassistant/components/doorbird/device.py
@@ -5,9 +5,11 @@
from collections import defaultdict
from dataclasses import dataclass
from functools import cached_property
+from http import HTTPStatus
import logging
from typing import Any
+from aiohttp import ClientResponseError
from doorbirdpy import (
DoorBird,
DoorBirdScheduleEntry,
@@ -170,15 +172,21 @@ async def _async_get_event_config(
) -> DoorbirdEventConfig:
"""Get events and unconfigured favorites from http favorites."""
device = self.device
- schedule = await device.schedule()
+ events: list[DoorbirdEvent] = []
+ unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
+ try:
+ schedule = await device.schedule()
+ except ClientResponseError as ex:
+ if ex.status == HTTPStatus.NOT_FOUND:
+ # D301 models do not support schedules
+ return DoorbirdEventConfig(events, [], unconfigured_favorites)
+ raise
favorite_input_type = {
output.param: entry.input
for entry in schedule
for output in entry.output
if output.event == HTTP_EVENT_TYPE
}
- events: list[DoorbirdEvent] = []
- unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
default_event_types = {
self._get_event_name(event): event_type
for event, event_type in DEFAULT_EVENT_TYPES
@@ -187,7 +195,7 @@ async def _async_get_event_config(
title: str | None = data.get("title")
if not title or not title.startswith("Home Assistant"):
continue
- event = title.split("(")[1].strip(")")
+ event = title.partition("(")[2].strip(")")
if input_type := favorite_input_type.get(identifier):
events.append(DoorbirdEvent(event, input_type))
elif input_type := default_event_types.get(event):
diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json
index e77f9aaf0a413f..0e9f03c8ef8da3 100644
--- a/homeassistant/components/doorbird/manifest.json
+++ b/homeassistant/components/doorbird/manifest.json
@@ -3,7 +3,7 @@
"name": "DoorBird",
"codeowners": ["@oblogic7", "@bdraco", "@flacjacket"],
"config_flow": true,
- "dependencies": ["http"],
+ "dependencies": ["http", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/doorbird",
"iot_class": "local_push",
"loggers": ["doorbirdpy"],
diff --git a/homeassistant/components/doorbird/repairs.py b/homeassistant/components/doorbird/repairs.py
new file mode 100644
index 00000000000000..c8f9b73ecbd51b
--- /dev/null
+++ b/homeassistant/components/doorbird/repairs.py
@@ -0,0 +1,55 @@
+"""Repairs for DoorBird."""
+
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import RepairsFlow
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+
+
+class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
+ """Handler to show doorbird error and reload."""
+
+ def __init__(self, entry_id: str) -> None:
+ """Initialize the flow."""
+ self.entry_id = entry_id
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the first step of a fix flow."""
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+ if user_input is not None:
+ self.hass.config_entries.async_schedule_reload(self.entry_id)
+ return self.async_create_entry(data={})
+
+ issue_registry = ir.async_get(self.hass)
+ description_placeholders = None
+ if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
+ description_placeholders = issue.translation_placeholders
+
+ return self.async_show_form(
+ step_id="confirm",
+ data_schema=vol.Schema({}),
+ description_placeholders=description_placeholders,
+ )
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ assert data is not None
+ entry_id = data["entry_id"]
+ assert isinstance(entry_id, str)
+ return DoorBirdReloadConfirmRepairFlow(entry_id=entry_id)
diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json
index 29c85ec73113ac..090ba4f161f1d8 100644
--- a/homeassistant/components/doorbird/strings.json
+++ b/homeassistant/components/doorbird/strings.json
@@ -11,6 +11,19 @@
}
}
},
+ "issues": {
+ "error_registering_events": {
+ "title": "DoorBird {name} configuration failure",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::doorbird::issues::error_registering_events::title%]",
+ "description": "Configuring DoorBird {name} failed with error: `{error}`. Please enable the API Operator permission for the DoorBird user and continue to reload the integration."
+ }
+ }
+ }
+ }
+ },
"config": {
"step": {
"user": {
diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json
index 5490b2a6503855..561f06d1bbe8c0 100644
--- a/homeassistant/components/dsmr/manifest.json
+++ b/homeassistant/components/dsmr/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "dsmr",
"name": "DSMR Smart Meter",
- "codeowners": ["@Robbie1221", "@frenck"],
+ "codeowners": ["@Robbie1221"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dsmr",
"integration_type": "hub",
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index f794d1d05e9df4..77c40c5c2921b8 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -431,39 +431,42 @@ def rename_old_gas_to_mbus(
) -> None:
"""Rename old gas sensor to mbus variant."""
dev_reg = dr.async_get(hass)
- device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
- if device_entry_v1 is not None:
- device_id = device_entry_v1.id
-
- ent_reg = er.async_get(hass)
- entries = er.async_entries_for_device(ent_reg, device_id)
-
- for entity in entries:
- if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
- try:
- ent_reg.async_update_entity(
- entity.entity_id,
- new_unique_id=mbus_device_id,
- device_id=mbus_device_id,
- )
- except ValueError:
- LOGGER.debug(
- "Skip migration of %s because it already exists",
- entity.entity_id,
- )
- else:
- LOGGER.debug(
- "Migrated entity %s from unique id %s to %s",
- entity.entity_id,
- entity.unique_id,
- mbus_device_id,
- )
- # Cleanup old device
- dev_entities = er.async_entries_for_device(
- ent_reg, device_id, include_disabled_entities=True
- )
- if not dev_entities:
- dev_reg.async_remove_device(device_id)
+ for dev_id in (mbus_device_id, entry.entry_id):
+ device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, dev_id)})
+ if device_entry_v1 is not None:
+ device_id = device_entry_v1.id
+
+ ent_reg = er.async_get(hass)
+ entries = er.async_entries_for_device(ent_reg, device_id)
+
+ for entity in entries:
+ if entity.unique_id.endswith(
+ "belgium_5min_gas_meter_reading"
+ ) or entity.unique_id.endswith("hourly_gas_meter_reading"):
+ try:
+ ent_reg.async_update_entity(
+ entity.entity_id,
+ new_unique_id=mbus_device_id,
+ device_id=mbus_device_id,
+ )
+ except ValueError:
+ LOGGER.debug(
+ "Skip migration of %s because it already exists",
+ entity.entity_id,
+ )
+ else:
+ LOGGER.debug(
+ "Migrated entity %s from unique id %s to %s",
+ entity.entity_id,
+ entity.unique_id,
+ mbus_device_id,
+ )
+ # Cleanup old device
+ dev_entities = er.async_entries_for_device(
+ ent_reg, device_id, include_disabled_entities=True
+ )
+ if not dev_entities:
+ dev_reg.async_remove_device(device_id)
def is_supported_description(
diff --git a/homeassistant/components/dunehd/config_flow.py b/homeassistant/components/dunehd/config_flow.py
index 8a0f3eec4a045d..33ffd4a812afdf 100644
--- a/homeassistant/components/dunehd/config_flow.py
+++ b/homeassistant/components/dunehd/config_flow.py
@@ -39,7 +39,7 @@ async def async_step_user(
try:
if self.host_already_configured(host):
- raise AlreadyConfigured
+ raise AlreadyConfigured # noqa: TRY301
await self.init_device(host)
except CannotConnect:
errors[CONF_HOST] = "cannot_connect"
diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py
index 01e0567ac8dc83..10109189eb0fa1 100644
--- a/homeassistant/components/dweet/sensor.py
+++ b/homeassistant/components/dweet/sensor.py
@@ -51,8 +51,6 @@ def setup_platform(
device = config.get(CONF_DEVICE)
value_template = config.get(CONF_VALUE_TEMPLATE)
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
- if value_template is not None:
- value_template.hass = hass
try:
content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"])
@@ -60,7 +58,7 @@ def setup_platform(
_LOGGER.error("Device/thing %s could not be found", device)
return
- if value_template.render_with_possible_json_value(content) == "":
+ if value_template and value_template.render_with_possible_json_value(content) == "":
_LOGGER.error("%s was not found", value_template)
return
diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py
index d13a337057da45..f8abf87ef2731b 100644
--- a/homeassistant/components/ecovacs/__init__.py
+++ b/homeassistant/components/ecovacs/__init__.py
@@ -1,31 +1,13 @@
"""Support for Ecovacs Deebot vacuums."""
from sucks import VacBot
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.typing import ConfigType
-from .const import CONF_CONTINENT, DOMAIN
from .controller import EcovacsController
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
- vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -41,17 +23,6 @@
type EcovacsConfigEntry = ConfigEntry[EcovacsController]
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Ecovacs component."""
- if DOMAIN in config:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
- )
- )
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
"""Set up this integration using UI."""
controller = EcovacsController(hass, entry.data)
diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py
index a254731a9463c4..2637dbbddf89c1 100644
--- a/homeassistant/components/ecovacs/config_flow.py
+++ b/homeassistant/components/ecovacs/config_flow.py
@@ -2,9 +2,10 @@
from __future__ import annotations
+from functools import partial
import logging
import ssl
-from typing import Any, cast
+from typing import Any
from urllib.parse import urlparse
from aiohttp import ClientError
@@ -13,21 +14,16 @@
from deebot_client.exceptions import InvalidAuthenticationError, MqttError
from deebot_client.mqtt_client import MqttClient, create_mqtt_config
from deebot_client.util import md5
-from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import AbortFlow
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, selector
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import VolDictType
-from homeassistant.loader import async_get_issue_tracker
from homeassistant.util.ssl import get_default_no_verify_context
from .const import (
- CONF_CONTINENT,
CONF_OVERRIDE_MQTT_URL,
CONF_OVERRIDE_REST_URL,
CONF_VERIFY_MQTT_CERTIFICATE,
@@ -105,11 +101,14 @@ async def _validate_input(
if not user_input.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url:
ssl_context = get_default_no_verify_context()
- mqtt_config = create_mqtt_config(
- device_id=device_id,
- country=country,
- override_mqtt_url=mqtt_url,
- ssl_context=ssl_context,
+ mqtt_config = await hass.async_add_executor_job(
+ partial(
+ create_mqtt_config,
+ device_id=device_id,
+ country=country,
+ override_mqtt_url=mqtt_url,
+ ssl_context=ssl_context,
+ )
)
client = MqttClient(mqtt_config, authenticator)
@@ -218,98 +217,3 @@ async def async_step_auth(
errors=errors,
last_step=True,
)
-
- async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
- """Import configuration from yaml."""
-
- def create_repair(
- error: str | None = None, placeholders: dict[str, Any] | None = None
- ) -> None:
- if placeholders is None:
- placeholders = {}
- if error:
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{error}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{error}",
- translation_placeholders=placeholders
- | {"url": "/config/integrations/dashboard/add?domain=ecovacs"},
- )
- else:
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders=placeholders
- | {
- "domain": DOMAIN,
- "integration_title": "Ecovacs",
- },
- )
-
- # We need to validate the imported country and continent
- # as the YAML configuration allows any string for them.
- # The config flow allows only valid alpha-2 country codes
- # through the CountrySelector.
- # The continent will be calculated with the function get_continent
- # from the country code and there is no need to specify the continent anymore.
- # As the YAML configuration includes the continent,
- # we check if both the entered continent and the calculated continent match.
- # If not we will inform the user about the mismatch.
- error = None
- placeholders = None
-
- # Convert the country to upper case as ISO 3166-1 alpha-2 country codes are upper case
- user_input[CONF_COUNTRY] = user_input[CONF_COUNTRY].upper()
-
- if len(user_input[CONF_COUNTRY]) != 2:
- error = "invalid_country_length"
- placeholders = {"countries_url": "https://www.iso.org/obp/ui/#search/code/"}
- elif len(user_input[CONF_CONTINENT]) != 2:
- error = "invalid_continent_length"
- placeholders = {
- "continent_list": ",".join(
- sorted(set(COUNTRIES_TO_CONTINENTS.values()))
- )
- }
- elif user_input[CONF_CONTINENT].lower() != (
- continent := get_continent(user_input[CONF_COUNTRY])
- ):
- error = "continent_not_match"
- placeholders = {
- "continent": continent,
- "github_issue_url": cast(
- str, async_get_issue_tracker(self.hass, integration_domain=DOMAIN)
- ),
- }
-
- if error:
- create_repair(error, placeholders)
- return self.async_abort(reason=error)
-
- # Remove the continent from the user input as it is not needed anymore
- user_input.pop(CONF_CONTINENT)
- try:
- result = await self.async_step_auth(user_input)
- except AbortFlow as ex:
- if ex.reason == "already_configured":
- create_repair()
- raise
-
- if errors := result.get("errors"):
- error = errors["base"]
- create_repair(error)
- return self.async_abort(reason=error)
-
- create_repair()
- return result
diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py
index 65044c016f91fb..ac7a268f1bd125 100644
--- a/homeassistant/components/ecovacs/const.py
+++ b/homeassistant/components/ecovacs/const.py
@@ -21,6 +21,12 @@
LifeSpan.ROUND_MOP,
)
+LEGACY_SUPPORTED_LIFESPANS = (
+ "main_brush",
+ "side_brush",
+ "filter",
+)
+
class InstanceMode(StrEnum):
"""Instance mode."""
diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py
index 0bef2e8fdd704d..ec67845cf9f74d 100644
--- a/homeassistant/components/ecovacs/controller.py
+++ b/homeassistant/components/ecovacs/controller.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+from functools import partial
import logging
import ssl
from typing import Any
@@ -64,30 +65,28 @@ def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None:
if not config.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url:
ssl_context = get_default_no_verify_context()
- self._mqtt = MqttClient(
- create_mqtt_config(
- device_id=self._device_id,
- country=country,
- override_mqtt_url=mqtt_url,
- ssl_context=ssl_context,
- ),
- self._authenticator,
+ self._mqtt_config_fn = partial(
+ create_mqtt_config,
+ device_id=self._device_id,
+ country=country,
+ override_mqtt_url=mqtt_url,
+ ssl_context=ssl_context,
)
+ self._mqtt_client: MqttClient | None = None
+
+ self._added_legacy_entities: set[str] = set()
async def initialize(self) -> None:
"""Init controller."""
- mqtt_config_verfied = False
try:
devices = await self._api_client.get_devices()
credentials = await self._authenticator.authenticate()
for device_config in devices:
if isinstance(device_config, DeviceInfo):
# MQTT device
- if not mqtt_config_verfied:
- await self._mqtt.verify_config()
- mqtt_config_verfied = True
device = Device(device_config, self._authenticator)
- await device.initialize(self._mqtt)
+ mqtt = await self._get_mqtt_client()
+ await device.initialize(mqtt)
self._devices.append(device)
else:
# Legacy device
@@ -114,9 +113,28 @@ async def teardown(self) -> None:
await device.teardown()
for legacy_device in self._legacy_devices:
await self._hass.async_add_executor_job(legacy_device.disconnect)
- await self._mqtt.disconnect()
+ if self._mqtt_client is not None:
+ await self._mqtt_client.disconnect()
await self._authenticator.teardown()
+ def add_legacy_entity(self, device: VacBot, component: str) -> None:
+ """Add legacy entity."""
+ self._added_legacy_entities.add(f"{device.vacuum['did']}_{component}")
+
+ def legacy_entity_is_added(self, device: VacBot, component: str) -> bool:
+ """Check if legacy entity is added."""
+ return f"{device.vacuum['did']}_{component}" in self._added_legacy_entities
+
+ async def _get_mqtt_client(self) -> MqttClient:
+ """Return validated MQTT client."""
+ if self._mqtt_client is None:
+ config = await self._hass.async_add_executor_job(self._mqtt_config_fn)
+ mqtt = MqttClient(config, self._authenticator)
+ await mqtt.verify_config()
+ self._mqtt_client = mqtt
+
+ return self._mqtt_client
+
@property
def devices(self) -> list[Device]:
"""Return devices."""
diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py
index 9d3092f37b43c9..36103be4d1146f 100644
--- a/homeassistant/components/ecovacs/entity.py
+++ b/homeassistant/components/ecovacs/entity.py
@@ -150,6 +150,11 @@ def __init__(self, device: VacBot) -> None:
self._event_listeners: list[EventListener] = []
+ @property
+ def available(self) -> bool:
+ """Return True if the entity is available."""
+ return super().available and self.state is not None
+
async def async_will_remove_from_hass(self) -> None:
"""Remove event listeners on entity remove."""
for listener in self._event_listeners:
diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json
index d129273e891bde..0c7178ced84e62 100644
--- a/homeassistant/components/ecovacs/icons.json
+++ b/homeassistant/components/ecovacs/icons.json
@@ -43,6 +43,9 @@
"clean_count": {
"default": "mdi:counter"
},
+ "cut_direction": {
+ "default": "mdi:angle-acute"
+ },
"volume": {
"default": "mdi:volume-high",
"state": {
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 8838eb4f50acd3..560ee4d599c955 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.10", "deebot-client==8.2.0"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==8.3.0"]
}
diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py
index 3b24091ca342f2..2b9bdc1a4250fe 100644
--- a/homeassistant/components/ecovacs/number.py
+++ b/homeassistant/components/ecovacs/number.py
@@ -7,14 +7,14 @@
from typing import Generic
from deebot_client.capabilities import CapabilitySet
-from deebot_client.events import CleanCountEvent, VolumeEvent
+from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
-from homeassistant.const import EntityCategory
+from homeassistant.const import DEGREE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -53,6 +53,18 @@ class EcovacsNumberEntityDescription(
native_max_value=10,
native_step=1.0,
),
+ EcovacsNumberEntityDescription[CutDirectionEvent](
+ capability_fn=lambda caps: caps.settings.cut_direction,
+ value_fn=lambda e: e.angle,
+ key="cut_direction",
+ translation_key="cut_direction",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=0,
+ native_max_value=180,
+ native_step=1.0,
+ native_unit_of_measurement=DEGREE,
+ ),
EcovacsNumberEntityDescription[CleanCountEvent](
capability_fn=lambda caps: caps.clean.count,
value_fn=lambda e: e.count,
diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py
index 256198693fb539..28c4efbd0c6341 100644
--- a/homeassistant/components/ecovacs/sensor.py
+++ b/homeassistant/components/ecovacs/sensor.py
@@ -4,7 +4,7 @@
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Generic
+from typing import Any, Generic
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan
from deebot_client.events import (
@@ -17,6 +17,7 @@
StatsEvent,
TotalStatsEvent,
)
+from sucks import VacBot
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -37,11 +38,12 @@
from homeassistant.helpers.typing import StateType
from . import EcovacsConfigEntry
-from .const import SUPPORTED_LIFESPANS
+from .const import LEGACY_SUPPORTED_LIFESPANS, SUPPORTED_LIFESPANS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
EcovacsEntity,
+ EcovacsLegacyEntity,
EventT,
)
from .util import get_supported_entitites
@@ -158,6 +160,25 @@ class EcovacsLifespanSensorEntityDescription(SensorEntityDescription):
)
+@dataclass(kw_only=True, frozen=True)
+class EcovacsLegacyLifespanSensorEntityDescription(SensorEntityDescription):
+ """Ecovacs lifespan sensor entity description."""
+
+ component: str
+
+
+LEGACY_LIFESPAN_SENSORS = tuple(
+ EcovacsLegacyLifespanSensorEntityDescription(
+ component=component,
+ key=f"lifespan_{component}",
+ translation_key=f"lifespan_{component}",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ for component in LEGACY_SUPPORTED_LIFESPANS
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
@@ -183,6 +204,32 @@ async def async_setup_entry(
async_add_entities(entities)
+ async def _add_legacy_entities() -> None:
+ entities = []
+ for device in controller.legacy_devices:
+ for description in LEGACY_LIFESPAN_SENSORS:
+ if (
+ description.component in device.components
+ and not controller.legacy_entity_is_added(
+ device, description.component
+ )
+ ):
+ controller.add_legacy_entity(device, description.component)
+ entities.append(EcovacsLegacyLifespanSensor(device, description))
+
+ if entities:
+ async_add_entities(entities)
+
+ def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None:
+ hass.create_task(_add_legacy_entities())
+
+ for device in controller.legacy_devices:
+ config_entry.async_on_unload(
+ device.lifespanEvents.subscribe(
+ _fire_ecovacs_legacy_lifespan_event
+ ).unsubscribe
+ )
+
class EcovacsSensor(
EcovacsDescriptionEntity[CapabilityEvent],
@@ -253,3 +300,36 @@ async def on_event(event: ErrorEvent) -> None:
self.async_write_ha_state()
self._subscribe(self._capability.event, on_event)
+
+
+class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity):
+ """Legacy Lifespan sensor."""
+
+ entity_description: EcovacsLegacyLifespanSensorEntityDescription
+
+ def __init__(
+ self,
+ device: VacBot,
+ description: EcovacsLegacyLifespanSensorEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(device)
+ self.entity_description = description
+ self._attr_unique_id = f"{device.vacuum['did']}_{description.key}"
+
+ if (value := device.components.get(description.component)) is not None:
+ value = int(value * 100)
+ self._attr_native_value = value
+
+ async def async_added_to_hass(self) -> None:
+ """Set up the event listeners now that hass is ready."""
+
+ def on_event(_: Any) -> None:
+ if (
+ value := self.device.components.get(self.entity_description.component)
+ ) is not None:
+ value = int(value * 100)
+ self._attr_native_value = value
+ self.schedule_update_ha_state()
+
+ self._event_listeners.append(self.device.lifespanEvents.subscribe(on_event))
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index d501c333a0336d..8222cabed07226 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -91,6 +91,9 @@
"clean_count": {
"name": "Clean count"
},
+ "cut_direction": {
+ "name": "Cut direction"
+ },
"volume": {
"name": "Volume"
}
@@ -116,6 +119,9 @@
"lifespan_lens_brush": {
"name": "Lens brush lifespan"
},
+ "lifespan_main_brush": {
+ "name": "[%key:component::ecovacs::entity::sensor::lifespan_brush::name%]"
+ },
"lifespan_side_brush": {
"name": "Side brush lifespan"
},
@@ -231,32 +237,6 @@
"message": "Getting the positions of the chargers and the device itself is not supported"
}
},
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_invalid_auth": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_invalid_country_length": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there is an invalid country specified in the YAML configuration.\n\nPlease change the country to the [Alpha-2 code of your country]({countries_url}) and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_invalid_continent_length": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there is an invalid continent specified in the YAML configuration.\n\nPlease correct the continent to be one of {continent_list} and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_continent_not_match": {
- "title": "The Ecovacs YAML configuration import failed",
- "description": "Configuring Ecovacs using YAML is being removed but there is an unexpected continent specified in the YAML configuration.\n\nFrom the given country, the continent \"{continent}\" is expected. Change the continent and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.\n\nIf the contintent \"{continent}\" is not applicable, please open an issue on [GitHub]({github_issue_url})."
- }
- },
"selector": {
"installation_mode": {
"options": {
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index d28e632580f9ee..0d14267e08d4b8 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -65,7 +65,7 @@ async def async_setup_entry(
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_RAW_GET_POSITIONS,
- {},
+ None,
"async_raw_get_positions",
supports_response=SupportsResponse.ONLY,
)
@@ -142,11 +142,6 @@ def state(self) -> str | None:
return None
- @property
- def available(self) -> bool:
- """Return True if the vacuum is available."""
- return super().available and self.state is not None
-
@property
def battery_level(self) -> int | None:
"""Return the battery level of the vacuum cleaner."""
@@ -173,6 +168,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
data: dict[str, Any] = {}
data[ATTR_ERROR] = self.error
+ # these attributes are deprecated and can be removed in 2025.2
for key, val in self.device.components.items():
attr_name = ATTR_COMPONENT_PREFIX + key
data[attr_name] = int(val * 100)
diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py
index 9ff4b9af94f88a..89dae7d23c9d9a 100644
--- a/homeassistant/components/egardia/__init__.py
+++ b/homeassistant/components/egardia/__init__.py
@@ -113,7 +113,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
server = egardiaserver.EgardiaServer("", rs_port)
bound = server.bind()
if not bound:
- raise OSError(
+ raise OSError( # noqa: TRY301
"Binding error occurred while starting EgardiaServer."
)
hass.data[EGARDIA_SERVER] = server
diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py
new file mode 100644
index 00000000000000..99cddd783e25ef
--- /dev/null
+++ b/homeassistant/components/elevenlabs/__init__.py
@@ -0,0 +1,71 @@
+"""The ElevenLabs text-to-speech integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from elevenlabs import Model
+from elevenlabs.client import AsyncElevenLabs
+from elevenlabs.core import ApiError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError
+
+from .const import CONF_MODEL
+
+PLATFORMS: list[Platform] = [Platform.TTS]
+
+
+async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
+ """Get ElevenLabs model from their API by the model_id."""
+ models = await client.models.get_all()
+ for maybe_model in models:
+ if maybe_model.model_id == model_id:
+ return maybe_model
+ return None
+
+
+@dataclass(kw_only=True, slots=True)
+class ElevenLabsData:
+ """ElevenLabs data type."""
+
+ client: AsyncElevenLabs
+ model: Model
+
+
+type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool:
+ """Set up ElevenLabs text-to-speech from a config entry."""
+ entry.add_update_listener(update_listener)
+ client = AsyncElevenLabs(api_key=entry.data[CONF_API_KEY])
+ model_id = entry.options[CONF_MODEL]
+ try:
+ model = await get_model_by_id(client, model_id)
+ except ApiError as err:
+ raise ConfigEntryError("Auth failed") from err
+
+ if model is None or (not model.languages):
+ raise ConfigEntryError("Model could not be resolved")
+
+ entry.runtime_data = ElevenLabsData(client=client, model=model)
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: EleventLabsConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def update_listener(
+ hass: HomeAssistant, config_entry: EleventLabsConfigEntry
+) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(config_entry.entry_id)
diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py
new file mode 100644
index 00000000000000..cf04304510accb
--- /dev/null
+++ b/homeassistant/components/elevenlabs/config_flow.py
@@ -0,0 +1,145 @@
+"""Config flow for ElevenLabs text-to-speech integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from elevenlabs.client import AsyncElevenLabs
+from elevenlabs.core import ApiError
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+ OptionsFlowWithConfigEntry,
+)
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+)
+
+from .const import CONF_MODEL, CONF_VOICE, DEFAULT_MODEL, DOMAIN
+
+USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def get_voices_models(api_key: str) -> tuple[dict[str, str], dict[str, str]]:
+ """Get available voices and models as dicts."""
+ client = AsyncElevenLabs(api_key=api_key)
+ voices = (await client.voices.get_all()).voices
+ models = await client.models.get_all()
+ voices_dict = {
+ voice.voice_id: voice.name
+ for voice in sorted(voices, key=lambda v: v.name or "")
+ if voice.name
+ }
+ models_dict = {
+ model.model_id: model.name
+ for model in sorted(models, key=lambda m: m.name or "")
+ if model.name and model.can_do_text_to_speech
+ }
+ return voices_dict, models_dict
+
+
+class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for ElevenLabs text-to-speech."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ try:
+ voices, _ = await get_voices_models(user_input[CONF_API_KEY])
+ except ApiError:
+ errors["base"] = "invalid_api_key"
+ else:
+ return self.async_create_entry(
+ title="ElevenLabs",
+ data=user_input,
+ options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]},
+ )
+ return self.async_show_form(
+ step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors
+ )
+
+ @staticmethod
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> OptionsFlow:
+ """Create the options flow."""
+ return ElevenLabsOptionsFlow(config_entry)
+
+
+class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry):
+ """ElevenLabs options flow."""
+
+ def __init__(self, config_entry: ConfigEntry) -> None:
+ """Initialize options flow."""
+ super().__init__(config_entry)
+ self.api_key: str = self.config_entry.data[CONF_API_KEY]
+ # id -> name
+ self.voices: dict[str, str] = {}
+ self.models: dict[str, str] = {}
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options."""
+ if not self.voices or not self.models:
+ self.voices, self.models = await get_voices_models(self.api_key)
+
+ assert self.models and self.voices
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title="ElevenLabs",
+ data=user_input,
+ )
+
+ schema = self.elevenlabs_config_option_schema()
+ return self.async_show_form(
+ step_id="init",
+ data_schema=schema,
+ )
+
+ def elevenlabs_config_option_schema(self) -> vol.Schema:
+ """Elevenlabs options schema."""
+ return self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_MODEL,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(label=model_name, value=model_id)
+ for model_id, model_name in self.models.items()
+ ]
+ )
+ ),
+ vol.Required(
+ CONF_VOICE,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(label=voice_name, value=voice_id)
+ for voice_id, voice_name in self.voices.items()
+ ]
+ )
+ ),
+ }
+ ),
+ self.options,
+ )
diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py
new file mode 100644
index 00000000000000..c0fc3c7b1b07f5
--- /dev/null
+++ b/homeassistant/components/elevenlabs/const.py
@@ -0,0 +1,7 @@
+"""Constants for the ElevenLabs text-to-speech integration."""
+
+CONF_VOICE = "voice"
+CONF_MODEL = "model"
+DOMAIN = "elevenlabs"
+
+DEFAULT_MODEL = "eleven_multilingual_v2"
diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json
new file mode 100644
index 00000000000000..968ea7b688a362
--- /dev/null
+++ b/homeassistant/components/elevenlabs/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "elevenlabs",
+ "name": "ElevenLabs",
+ "codeowners": ["@sorgfresser"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/elevenlabs",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["elevenlabs"],
+ "requirements": ["elevenlabs==1.6.1"]
+}
diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json
new file mode 100644
index 00000000000000..16b40137090880
--- /dev/null
+++ b/homeassistant/components/elevenlabs/strings.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "Your Elevenlabs API key."
+ }
+ }
+ },
+ "error": {
+ "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "voice": "Voice",
+ "model": "Model"
+ },
+ "data_description": {
+ "voice": "Voice to use for the TTS.",
+ "model": "ElevenLabs model to use. Please note that not all models support all languages equally well."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py
new file mode 100644
index 00000000000000..35ba6053cd893d
--- /dev/null
+++ b/homeassistant/components/elevenlabs/tts.py
@@ -0,0 +1,116 @@
+"""Support for the ElevenLabs text-to-speech service."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from elevenlabs.client import AsyncElevenLabs
+from elevenlabs.core import ApiError
+from elevenlabs.types import Model, Voice as ElevenLabsVoice
+
+from homeassistant.components.tts import (
+ ATTR_VOICE,
+ TextToSpeechEntity,
+ TtsAudioType,
+ Voice,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import EleventLabsConfigEntry
+from .const import CONF_VOICE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: EleventLabsConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up ElevenLabs tts platform via config entry."""
+ client = config_entry.runtime_data.client
+ voices = (await client.voices.get_all()).voices
+ default_voice_id = config_entry.options[CONF_VOICE]
+ async_add_entities(
+ [
+ ElevenLabsTTSEntity(
+ client,
+ config_entry.runtime_data.model,
+ voices,
+ default_voice_id,
+ config_entry.entry_id,
+ config_entry.title,
+ )
+ ]
+ )
+
+
+class ElevenLabsTTSEntity(TextToSpeechEntity):
+ """The ElevenLabs API entity."""
+
+ _attr_supported_options = [ATTR_VOICE]
+
+ def __init__(
+ self,
+ client: AsyncElevenLabs,
+ model: Model,
+ voices: list[ElevenLabsVoice],
+ default_voice_id: str,
+ entry_id: str,
+ title: str,
+ ) -> None:
+ """Init ElevenLabs TTS service."""
+ self._client = client
+ self._model = model
+ self._default_voice_id = default_voice_id
+ self._voices = sorted(
+ (Voice(v.voice_id, v.name) for v in voices if v.name),
+ key=lambda v: v.name,
+ )
+ # Default voice first
+ voice_indices = [
+ idx for idx, v in enumerate(self._voices) if v.voice_id == default_voice_id
+ ]
+ if voice_indices:
+ self._voices.insert(0, self._voices.pop(voice_indices[0]))
+ self._attr_unique_id = entry_id
+ self._attr_name = title
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entry_id)},
+ manufacturer="ElevenLabs",
+ model=model.name,
+ entry_type=DeviceEntryType.SERVICE,
+ )
+ self._attr_supported_languages = [
+ lang.language_id for lang in self._model.languages or []
+ ]
+ self._attr_default_language = self.supported_languages[0]
+
+ def async_get_supported_voices(self, language: str) -> list[Voice]:
+ """Return a list of supported voices for a language."""
+ return self._voices
+
+ async def async_get_tts_audio(
+ self, message: str, language: str, options: dict[str, Any]
+ ) -> TtsAudioType:
+ """Load tts audio file from the engine."""
+ _LOGGER.debug("Getting TTS audio for %s", message)
+ _LOGGER.debug("Options: %s", options)
+ voice_id = options[ATTR_VOICE]
+ try:
+ audio = await self._client.generate(
+ text=message,
+ voice=voice_id,
+ model=self._model.model_id,
+ )
+ bytes_combined = b"".join([byte_seg async for byte_seg in audio])
+ except ApiError as exc:
+ _LOGGER.warning(
+ "Error during processing of TTS request %s", exc, exc_info=True
+ )
+ raise HomeAssistantError(exc) from exc
+ return "mp3", bytes_combined
diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py
index 339bed97f6f83e..a62a26f21d3bc7 100644
--- a/homeassistant/components/elgato/light.py
+++ b/homeassistant/components/elgato/light.py
@@ -40,7 +40,7 @@ async def async_setup_entry(
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_IDENTIFY,
- {},
+ None,
ElgatoLight.async_identify.__name__,
)
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index 7d3601f0bd068f..16f877719a7b8a 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -56,7 +56,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_SENSOR_COUNTER_REFRESH,
- {},
+ None,
"async_counter_refresh",
)
platform.async_register_entity_service(
@@ -71,7 +71,7 @@ async def async_setup_entry(
)
platform.async_register_entity_service(
SERVICE_SENSOR_ZONE_TRIGGER,
- {},
+ None,
"async_zone_trigger",
)
diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py
index 2971a425663f40..69f69a5fd313db 100644
--- a/homeassistant/components/elmax/config_flow.py
+++ b/homeassistant/components/elmax/config_flow.py
@@ -424,7 +424,7 @@ async def async_step_reauth_confirm(
if p.hash == self._entry.data[CONF_ELMAX_PANEL_ID]
]
if len(panels) < 1:
- raise NoOnlinePanelsError
+ raise NoOnlinePanelsError # noqa: TRY301
# Verify the pin is still valid.
await client.get_panel_status(
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index c299c5a1b9fa20..3c44839197465e 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -87,9 +87,6 @@ async def async_setup_platform(
sensor_names = config.get(CONF_SENSOR_NAMES)
scan_interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=30))
- if value_template is not None:
- value_template.hass = hass
-
emoncms_client = EmoncmsClient(url, apikey, session=async_get_clientsession(hass))
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval)
await coordinator.async_refresh()
diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py
index d5fc8af1aa45f7..408d8c4eff8dce 100644
--- a/homeassistant/components/emulated_kasa/__init__.py
+++ b/homeassistant/components/emulated_kasa/__init__.py
@@ -95,8 +95,6 @@ async def validate_configs(hass, entity_configs):
power_val = entity_config[CONF_POWER]
if isinstance(power_val, str) and is_template_string(power_val):
entity_config[CONF_POWER] = Template(power_val, hass)
- elif isinstance(power_val, Template):
- entity_config[CONF_POWER].hass = hass
elif CONF_POWER_ENTITY in entity_config:
power_val = entity_config[CONF_POWER_ENTITY]
if hass.states.get(power_val) is None:
diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json
index 538cfb56388c54..1a0875b04c0418 100644
--- a/homeassistant/components/enigma2/manifest.json
+++ b/homeassistant/components/enigma2/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openwebif"],
- "requirements": ["openwebifpy==4.2.5"]
+ "requirements": ["openwebifpy==4.2.7"]
}
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index 09c55fb23acda7..aa06a1ff79f458 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
- "requirements": ["pyenphase==1.20.6"],
+ "requirements": ["pyenphase==1.22.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
index 2d54a313dde469..1871062c2e9a98 100644
--- a/homeassistant/components/environment_canada/weather.py
+++ b/homeassistant/components/environment_canada/weather.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import Any
+
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
@@ -190,10 +192,12 @@ def get_forecast(ec_data, hourly) -> list[Forecast] | None:
if not (half_days := ec_data.daily_forecasts):
return None
- def get_day_forecast(fcst: list[dict[str, str]]) -> Forecast:
+ def get_day_forecast(
+ fcst: list[dict[str, Any]],
+ ) -> Forecast:
high_temp = int(fcst[0]["temperature"]) if len(fcst) == 2 else None
return {
- ATTR_FORECAST_TIME: fcst[0]["timestamp"],
+ ATTR_FORECAST_TIME: fcst[0]["timestamp"].isoformat(),
ATTR_FORECAST_NATIVE_TEMP: high_temp,
ATTR_FORECAST_NATIVE_TEMP_LOW: int(fcst[-1]["temperature"]),
ATTR_FORECAST_PRECIPITATION_PROBABILITY: int(
diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py
index 75c448e84678e2..2ebb381341e6d2 100644
--- a/homeassistant/components/epic_games_store/calendar.py
+++ b/homeassistant/components/epic_games_store/calendar.py
@@ -16,7 +16,7 @@
from .const import DOMAIN, CalendarType
from .coordinator import EGSCalendarUpdateCoordinator
-DateRange = namedtuple("DateRange", ["start", "end"])
+DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024
async def async_setup_entry(
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index e8d002fba9d05a..7629d1fa9cd57a 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -197,9 +197,9 @@ def async_on_service_call(self, service: HomeassistantServiceCall) -> None:
if service.data_template:
try:
data_template = {
- key: Template(value) for key, value in service.data_template.items()
+ key: Template(value, hass)
+ for key, value in service.data_template.items()
}
- template.attach(hass, data_template)
service_data.update(
template.render_complex(data_template, service.variables)
)
@@ -346,7 +346,7 @@ async def _handle_pipeline_start(
) -> int | None:
"""Start a voice assistant pipeline."""
if self.voice_assistant_pipeline is not None:
- _LOGGER.warning("Voice assistant UDP server was not stopped")
+ _LOGGER.warning("Previous Voice assistant pipeline was not stopped")
self.voice_assistant_pipeline.stop()
self.voice_assistant_pipeline = None
@@ -654,12 +654,13 @@ def _async_setup_device_registry(
if device_info.manufacturer:
manufacturer = device_info.manufacturer
model = device_info.model
- hw_version = None
if device_info.project_name:
project_name = device_info.project_name.split(".")
manufacturer = project_name[0]
model = project_name[1]
- hw_version = device_info.project_version
+ sw_version = (
+ f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
+ )
suggested_area = None
if device_info.suggested_area:
@@ -674,7 +675,6 @@ def _async_setup_device_registry(
manufacturer=manufacturer,
model=model,
sw_version=sw_version,
- hw_version=hw_version,
suggested_area=suggested_area,
)
return device_entry.id
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index ff7569bbc5f5af..b2647709e8e5be 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
- "aioesphomeapi==24.6.2",
+ "aioesphomeapi==25.1.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==1.0.0"
],
diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py
index ed37a9a6ab8fb4..623946503ebf06 100644
--- a/homeassistant/components/esphome/select.py
+++ b/homeassistant/components/esphome/select.py
@@ -83,7 +83,7 @@ def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None:
class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect):
- """VAD sensitivity selector for VoIP devices."""
+ """VAD sensitivity selector for ESPHome devices."""
def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None:
"""Initialize a VAD sensitivity selector."""
diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py
index e86c88ddf5be98..b7905fb4fdbfe7 100644
--- a/homeassistant/components/esphome/update.py
+++ b/homeassistant/components/esphome/update.py
@@ -8,6 +8,7 @@
from aioesphomeapi import (
DeviceInfo as ESPHomeDeviceInfo,
EntityInfo,
+ UpdateCommand,
UpdateInfo,
UpdateState,
)
@@ -259,9 +260,15 @@ def title(self) -> str | None:
"""Return the title of the update."""
return self._state.title
+ @convert_api_error_ha_error
+ async def async_update(self) -> None:
+ """Command device to check for update."""
+ if self.available:
+ self._client.update_command(key=self._key, command=UpdateCommand.CHECK)
+
@convert_api_error_ha_error
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
- """Update the current value."""
- self._client.update_command(key=self._key, install=True)
+ """Command device to install update."""
+ self._client.update_command(key=self._key, command=UpdateCommand.INSTALL)
diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py
index a6cedee30ab61b..eb55be2ced623e 100644
--- a/homeassistant/components/esphome/voice_assistant.py
+++ b/homeassistant/components/esphome/voice_assistant.py
@@ -34,6 +34,7 @@
WakeWordDetectionAborted,
WakeWordDetectionError,
)
+from homeassistant.components.assist_pipeline.vad import VadSensitivity
from homeassistant.components.intent.timers import TimerEventType, TimerInfo
from homeassistant.components.media_player import async_process_play_media_url
from homeassistant.core import Context, HomeAssistant, callback
@@ -243,6 +244,11 @@ async def run_pipeline(
auto_gain_dbfs=audio_settings.auto_gain,
volume_multiplier=audio_settings.volume_multiplier,
is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD),
+ silence_seconds=VadSensitivity.to_seconds(
+ pipeline_select.get_vad_sensitivity(
+ self.hass, DOMAIN, self.device_info.mac_address
+ )
+ ),
),
)
diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py
index b83d2d20c6a660..943bd6605b49c1 100644
--- a/homeassistant/components/evohome/coordinator.py
+++ b/homeassistant/components/evohome/coordinator.py
@@ -19,7 +19,6 @@
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.event import async_call_later
from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET
from .helpers import handle_evo_exception
@@ -107,7 +106,7 @@ async def call_client_api(
return None
if update_state: # wait a moment for system to quiesce before updating state
- async_call_later(self.hass, 1, self._update_v2_api_state)
+ await self.hass.data[DOMAIN]["coordinator"].async_request_refresh()
return result
diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py
index 455c41b385f1f4..3c4a5f70ff48d6 100644
--- a/homeassistant/components/ezviz/camera.py
+++ b/homeassistant/components/ezviz/camera.py
@@ -112,7 +112,7 @@ async def async_setup_entry(
platform = async_get_current_platform()
platform.async_register_entity_service(
- SERVICE_WAKE_DEVICE, {}, "perform_wake_device"
+ SERVICE_WAKE_DEVICE, None, "perform_wake_device"
)
diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index 6ecc675a45e67b..5a15ece665a4ab 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -139,11 +139,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[FanEntityFeature.TURN_ON],
)
component.async_register_entity_service(
- SERVICE_TURN_OFF, {}, "async_turn_off", [FanEntityFeature.TURN_OFF]
+ SERVICE_TURN_OFF, None, "async_turn_off", [FanEntityFeature.TURN_OFF]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[FanEntityFeature.TURN_OFF, FanEntityFeature.TURN_ON],
)
diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json
index 8cd7b1f504df13..ab9f3ed65c1d61 100644
--- a/homeassistant/components/ffmpeg/manifest.json
+++ b/homeassistant/components/ffmpeg/manifest.json
@@ -3,5 +3,6 @@
"name": "FFmpeg",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg",
+ "integration_type": "system",
"requirements": ["ha-ffmpeg==3.2.0"]
}
diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py
index aa3e241cc8113d..0c9cfee5f4d487 100644
--- a/homeassistant/components/file/__init__.py
+++ b/homeassistant/components/file/__init__.py
@@ -1,5 +1,8 @@
"""The file component."""
+from copy import deepcopy
+from typing import Any
+
from homeassistant.components.notify import migrate_notify_issue
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
@@ -84,7 +87,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a file component entry."""
- config = dict(entry.data)
+ config = {**entry.data, **entry.options}
filepath: str = config[CONF_FILE_PATH]
if filepath and not await hass.async_add_executor_job(
hass.config.is_allowed_path, filepath
@@ -98,6 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, [Platform(entry.data[CONF_PLATFORM])]
)
+ entry.async_on_unload(entry.add_update_listener(update_listener))
if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data:
# New notify entities are being setup through the config entry,
# but during the deprecation period we want to keep the legacy notify platform,
@@ -121,3 +125,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(
entry, [entry.data[CONF_PLATFORM]]
)
+
+
+async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate config entry."""
+ if config_entry.version > 2:
+ # Downgraded from future
+ return False
+
+ if config_entry.version < 2:
+ # Move optional fields from data to options in config entry
+ data: dict[str, Any] = deepcopy(dict(config_entry.data))
+ options = {}
+ for key, value in config_entry.data.items():
+ if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
+ data.pop(key)
+ options[key] = value
+
+ hass.config_entries.async_update_entry(
+ config_entry, version=2, data=data, options=options
+ )
+ return True
diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py
index 2d729473929899..8cb58ec1f4701f 100644
--- a/homeassistant/components/file/config_flow.py
+++ b/homeassistant/components/file/config_flow.py
@@ -1,11 +1,18 @@
"""Config flow for file integration."""
+from copy import deepcopy
import os
from typing import Any
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+ OptionsFlowWithConfigEntry,
+)
from homeassistant.const import (
CONF_FILE_PATH,
CONF_FILENAME,
@@ -15,6 +22,7 @@
CONF_VALUE_TEMPLATE,
Platform,
)
+from homeassistant.core import callback
from homeassistant.helpers.selector import (
BooleanSelector,
BooleanSelectorConfig,
@@ -31,27 +39,44 @@
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
-FILE_FLOW_SCHEMAS = {
+FILE_OPTIONS_SCHEMAS = {
Platform.SENSOR.value: vol.Schema(
{
- vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
vol.Optional(CONF_VALUE_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): TEXT_SELECTOR,
}
),
Platform.NOTIFY.value: vol.Schema(
{
- vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
vol.Optional(CONF_TIMESTAMP, default=False): BOOLEAN_SELECTOR,
}
),
}
+FILE_FLOW_SCHEMAS = {
+ Platform.SENSOR.value: vol.Schema(
+ {
+ vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
+ }
+ ).extend(FILE_OPTIONS_SCHEMAS[Platform.SENSOR.value].schema),
+ Platform.NOTIFY.value: vol.Schema(
+ {
+ vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
+ }
+ ).extend(FILE_OPTIONS_SCHEMAS[Platform.NOTIFY.value].schema),
+}
+
class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a file config flow."""
- VERSION = 1
+ VERSION = 2
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
+ """Get the options flow for this handler."""
+ return FileOptionsFlowHandler(config_entry)
async def validate_file_path(self, file_path: str) -> bool:
"""Ensure the file path is valid."""
@@ -80,7 +105,13 @@ async def _async_handle_step(
errors[CONF_FILE_PATH] = "not_allowed"
else:
title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]"
- return self.async_create_entry(data=user_input, title=title)
+ data = deepcopy(user_input)
+ options = {}
+ for key, value in user_input.items():
+ if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
+ data.pop(key)
+ options[key] = value
+ return self.async_create_entry(data=data, title=title, options=options)
return self.async_show_form(
step_id=platform, data_schema=FILE_FLOW_SCHEMAS[platform], errors=errors
@@ -114,4 +145,29 @@ async def async_step_import(
else:
file_path = import_data[CONF_FILE_PATH]
title = f"{name} [{file_path}]"
- return self.async_create_entry(title=title, data=import_data)
+ data = deepcopy(import_data)
+ options = {}
+ for key, value in import_data.items():
+ if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
+ data.pop(key)
+ options[key] = value
+ return self.async_create_entry(title=title, data=data, options=options)
+
+
+class FileOptionsFlowHandler(OptionsFlowWithConfigEntry):
+ """Handle File options."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage File options."""
+ if user_input:
+ return self.async_create_entry(data=user_input)
+
+ platform = self.config_entry.data[CONF_PLATFORM]
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ FILE_OPTIONS_SCHEMAS[platform], self.config_entry.options or {}
+ ),
+ )
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
index 1516efd6d96e9a..9411b7cf1a8521 100644
--- a/homeassistant/components/file/notify.py
+++ b/homeassistant/components/file/notify.py
@@ -5,7 +5,6 @@
from functools import partial
import logging
import os
-from types import MappingProxyType
from typing import Any, TextIO
import voluptuous as vol
@@ -109,7 +108,7 @@ async def async_setup_entry(
) -> None:
"""Set up notify entity."""
unique_id = entry.entry_id
- async_add_entities([FileNotifyEntity(unique_id, entry.data)])
+ async_add_entities([FileNotifyEntity(unique_id, {**entry.data, **entry.options})])
class FileNotifyEntity(NotifyEntity):
@@ -118,7 +117,7 @@ class FileNotifyEntity(NotifyEntity):
_attr_icon = FILE_ICON
_attr_supported_features = NotifyEntityFeature.TITLE
- def __init__(self, unique_id: str, config: MappingProxyType[str, Any]) -> None:
+ def __init__(self, unique_id: str, config: dict[str, Any]) -> None:
"""Initialize the service."""
self._file_path: str = config[CONF_FILE_PATH]
self._add_timestamp: bool = config.get(CONF_TIMESTAMP, False)
diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py
index fda0d14a6aa907..e37a3df86a687b 100644
--- a/homeassistant/components/file/sensor.py
+++ b/homeassistant/components/file/sensor.py
@@ -60,14 +60,15 @@ async def async_setup_entry(
) -> None:
"""Set up the file sensor."""
config = dict(entry.data)
+ options = dict(entry.options)
file_path: str = config[CONF_FILE_PATH]
unique_id: str = entry.entry_id
name: str = config.get(CONF_NAME, DEFAULT_NAME)
- unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT)
+ unit: str | None = options.get(CONF_UNIT_OF_MEASUREMENT)
value_template: Template | None = None
- if CONF_VALUE_TEMPLATE in config:
- value_template = Template(config[CONF_VALUE_TEMPLATE], hass)
+ if CONF_VALUE_TEMPLATE in options:
+ value_template = Template(options[CONF_VALUE_TEMPLATE], hass)
async_add_entities(
[FileSensor(unique_id, name, file_path, unit, value_template)], True
diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json
index 9d49e6300e95d6..60ebf451f78d78 100644
--- a/homeassistant/components/file/strings.json
+++ b/homeassistant/components/file/strings.json
@@ -42,6 +42,22 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "value_template": "[%key:component::file::config::step::sensor::data::value_template%]",
+ "unit_of_measurement": "[%key:component::file::config::step::sensor::data::unit_of_measurement%]",
+ "timestamp": "[%key:component::file::config::step::notify::data::timestamp%]"
+ },
+ "data_description": {
+ "value_template": "[%key:component::file::config::step::sensor::data_description::value_template%]",
+ "unit_of_measurement": "[%key:component::file::config::step::sensor::data_description::unit_of_measurement%]",
+ "timestamp": "[%key:component::file::config::step::notify::data_description::timestamp%]"
+ }
+ }
+ }
+ },
"exceptions": {
"dir_not_allowed": {
"message": "Access to {filename} is not allowed."
diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py
index 37fba19fb4e66d..c0dbb14555ebd8 100644
--- a/homeassistant/components/filesize/coordinator.py
+++ b/homeassistant/components/filesize/coordinator.py
@@ -46,14 +46,15 @@ def _get_full_path(self) -> pathlib.Path:
def _update(self) -> os.stat_result:
"""Fetch file information."""
- if not hasattr(self, "path"):
- self.path = self._get_full_path()
-
try:
return self.path.stat()
except OSError as error:
raise UpdateFailed(f"Can not retrieve file statistics {error}") from error
+ async def _async_setup(self) -> None:
+ """Set up path."""
+ self.path = await self.hass.async_add_executor_job(self._get_full_path)
+
async def _async_update_data(self) -> dict[str, float | int | datetime]:
"""Fetch file information."""
statinfo = await self.hass.async_add_executor_job(self._update)
diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py
index 2f47fdc09ebb79..8a92850ad47d18 100644
--- a/homeassistant/components/fints/sensor.py
+++ b/homeassistant/components/fints/sensor.py
@@ -28,7 +28,7 @@
ICON = "mdi:currency-eur"
-BankCredentials = namedtuple("BankCredentials", "blz login pin url")
+BankCredentials = namedtuple("BankCredentials", "blz login pin url") # noqa: PYI024
CONF_BIN = "bank_identification_number"
CONF_ACCOUNTS = "accounts"
diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json
index 8fc146ded6ab33..0442e4a7b7bdda 100644
--- a/homeassistant/components/flic/manifest.json
+++ b/homeassistant/components/flic/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/flic",
"iot_class": "local_push",
"loggers": ["pyflic"],
- "requirements": ["pyflic==2.0.3"]
+ "requirements": ["pyflic==2.0.4"]
}
diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py
index ab201dfb906384..f0460839837e69 100644
--- a/homeassistant/components/flo/switch.py
+++ b/homeassistant/components/flo/switch.py
@@ -42,13 +42,13 @@ async def async_setup_entry(
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
- SERVICE_SET_AWAY_MODE, {}, "async_set_mode_away"
+ SERVICE_SET_AWAY_MODE, None, "async_set_mode_away"
)
platform.async_register_entity_service(
- SERVICE_SET_HOME_MODE, {}, "async_set_mode_home"
+ SERVICE_SET_HOME_MODE, None, "async_set_mode_home"
)
platform.async_register_entity_service(
- SERVICE_RUN_HEALTH_TEST, {}, "async_run_health_test"
+ SERVICE_RUN_HEALTH_TEST, None, "async_run_health_test"
)
platform.async_register_entity_service(
SERVICE_SET_SLEEP_MODE,
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 8a55084d7ef309..13c442a1ace5f3 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -568,8 +568,7 @@ async def async_scan_devices(self, now: datetime | None = None) -> None:
self.fritz_hosts.get_mesh_topology
)
):
- # pylint: disable-next=broad-exception-raised
- raise Exception("Mesh supported but empty topology reported")
+ raise Exception("Mesh supported but empty topology reported") # noqa: TRY002
except FritzActionError:
self.mesh_role = MeshRoles.SLAVE
# Avoid duplicating device trackers
@@ -653,8 +652,6 @@ async def async_trigger_cleanup(self) -> None:
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
entity_reg, config_entry.entry_id
)
-
- orphan_macs: set[str] = set()
for entity in entities:
entry_mac = entity.unique_id.split("_")[0]
if (
@@ -662,15 +659,16 @@ async def async_trigger_cleanup(self) -> None:
or "_internet_access" in entity.unique_id
) and entry_mac not in device_hosts:
_LOGGER.info("Removing orphan entity entry %s", entity.entity_id)
- orphan_macs.add(entry_mac)
entity_reg.async_remove(entity.entity_id)
device_reg = dr.async_get(self.hass)
- orphan_connections = {(CONNECTION_NETWORK_MAC, mac) for mac in orphan_macs}
+ valid_connections = {
+ (CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in device_hosts
+ }
for device in dr.async_entries_for_config_entry(
device_reg, config_entry.entry_id
):
- if any(con in device.connections for con in orphan_connections):
+ if not any(con in device.connections for con in valid_connections):
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id
diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py
index 689e64c709a906..1009b0fb3689aa 100644
--- a/homeassistant/components/fritzbox/light.py
+++ b/homeassistant/components/fritzbox/light.py
@@ -17,7 +17,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity
-from .const import COLOR_MODE, COLOR_TEMP_MODE, LOGGER
+from .const import COLOR_MODE, LOGGER
from .coordinator import FritzboxConfigEntry
SUPPORTED_COLOR_MODES = {ColorMode.COLOR_TEMP, ColorMode.HS}
@@ -72,22 +72,16 @@ def brightness(self) -> int:
return self.data.level # type: ignore [no-any-return]
@property
- def hs_color(self) -> tuple[float, float] | None:
+ def hs_color(self) -> tuple[float, float]:
"""Return the hs color value."""
- if self.data.color_mode != COLOR_MODE:
- return None
-
hue = self.data.hue
saturation = self.data.saturation
return (hue, float(saturation) * 100.0 / 255.0)
@property
- def color_temp_kelvin(self) -> int | None:
+ def color_temp_kelvin(self) -> int:
"""Return the CT color value."""
- if self.data.color_mode != COLOR_TEMP_MODE:
- return None
-
return self.data.color_temp # type: ignore [no-any-return]
@property
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index 5b462842e4a5e4..c5df84cf549a81 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -398,6 +398,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
static_paths_configs: list[StaticPathConfig] = []
for path, should_cache in (
+ ("service_worker.js", False),
("sw-modern.js", False),
("sw-modern.js.map", False),
("sw-legacy.js", False),
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index d7253b52b28605..035b087e48124f 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20240719.0"]
+ "requirements": ["home-assistant-frontend==20240809.0"]
}
diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py
index cb4f402d7bb948..988c66b679c806 100644
--- a/homeassistant/components/garadget/cover.py
+++ b/homeassistant/components/garadget/cover.py
@@ -213,23 +213,20 @@ def _check_state(self, now):
def close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
if self._state not in ["close", "closing"]:
- ret = self._put_command("setState", "close")
+ self._put_command("setState", "close")
self._start_watcher("close")
- return ret.get("return_value") == 1
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
if self._state not in ["open", "opening"]:
- ret = self._put_command("setState", "open")
+ self._put_command("setState", "open")
self._start_watcher("open")
- return ret.get("return_value") == 1
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the door where it is."""
if self._state not in ["stopped"]:
- ret = self._put_command("setState", "stop")
+ self._put_command("setState", "stop")
self._start_watcher("stop")
- return ret["return_value"] == 1
def update(self) -> None:
"""Get updated status from API."""
diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py
index 80971760b853e9..3aac5145ca56ea 100644
--- a/homeassistant/components/generic/camera.py
+++ b/homeassistant/components/generic/camera.py
@@ -28,10 +28,10 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.template import Template
from . import DOMAIN
from .const import (
@@ -91,18 +91,11 @@ def __init__(
self._password = device_info.get(CONF_PASSWORD)
self._name = device_info.get(CONF_NAME, title)
self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL)
- if (
- not isinstance(self._still_image_url, template_helper.Template)
- and self._still_image_url
- ):
- self._still_image_url = cv.template(self._still_image_url)
if self._still_image_url:
- self._still_image_url.hass = hass
+ self._still_image_url = Template(self._still_image_url, hass)
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
if self._stream_source:
- if not isinstance(self._stream_source, template_helper.Template):
- self._stream_source = cv.template(self._stream_source)
- self._stream_source.hass = hass
+ self._stream_source = Template(self._stream_source, hass)
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
if self._stream_source:
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index c142d15f9e5caf..2a118b70879d2d 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -485,7 +485,7 @@ def _async_update_temp(self, state: State) -> None:
try:
cur_temp = float(state.state)
if not math.isfinite(cur_temp):
- raise ValueError(f"Sensor has illegal state {state.state}")
+ raise ValueError(f"Sensor has illegal state {state.state}") # noqa: TRY301
self._cur_temp = cur_temp
except ValueError as ex:
_LOGGER.error("Unable to update from sensor: %s", ex)
diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py
index f83b39d1cf98c1..0ddd8a86979045 100644
--- a/homeassistant/components/glances/__init__.py
+++ b/homeassistant/components/glances/__init__.py
@@ -27,7 +27,6 @@
ConfigEntryNotReady,
HomeAssistantError,
)
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -36,7 +35,6 @@
PLATFORMS = [Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py
index 4e5bdcc154309d..8882b097ba905f 100644
--- a/homeassistant/components/glances/coordinator.py
+++ b/homeassistant/components/glances/coordinator.py
@@ -45,15 +45,13 @@ async def _async_update_data(self) -> dict[str, Any]:
except exceptions.GlancesApiError as err:
raise UpdateFailed from err
# Update computed values
- uptime: datetime | None = self.data["computed"]["uptime"] if self.data else None
+ uptime: datetime | None = None
up_duration: timedelta | None = None
- if up_duration := parse_duration(data.get("uptime")):
+ if "uptime" in data and (up_duration := parse_duration(data["uptime"])):
+ uptime = self.data["computed"]["uptime"] if self.data else None
# Update uptime if previous value is None or previous uptime is bigger than
# new uptime (i.e. server restarted)
- if (
- self.data is None
- or self.data["computed"]["uptime_duration"] > up_duration
- ):
+ if uptime is None or self.data["computed"]["uptime_duration"] > up_duration:
uptime = utcnow() - up_duration
data["computed"] = {"uptime_duration": up_duration, "uptime": uptime}
return data or {}
diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py
index a1cb8e47b9dff8..59eba69d60a6e6 100644
--- a/homeassistant/components/glances/sensor.py
+++ b/homeassistant/components/glances/sensor.py
@@ -325,6 +325,7 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit
entity_description: GlancesSensorEntityDescription
_attr_has_entity_name = True
+ _data_valid: bool = False
def __init__(
self,
@@ -351,14 +352,7 @@ def __init__(
@property
def available(self) -> bool:
"""Set sensor unavailable when native value is invalid."""
- if super().available:
- return (
- not self._numeric_state_expected
- or isinstance(value := self.native_value, (int, float))
- or isinstance(value, str)
- and value.isnumeric()
- )
- return False
+ return super().available and self._data_valid
@callback
def _handle_coordinator_update(self) -> None:
@@ -368,10 +362,19 @@ def _handle_coordinator_update(self) -> None:
def _update_native_value(self) -> None:
"""Update sensor native value from coordinator data."""
- data = self.coordinator.data[self.entity_description.type]
- if dict_val := data.get(self._sensor_label):
+ data = self.coordinator.data.get(self.entity_description.type)
+ if data and (dict_val := data.get(self._sensor_label)):
self._attr_native_value = dict_val.get(self.entity_description.key)
- elif self.entity_description.key in data:
+ elif data and (self.entity_description.key in data):
self._attr_native_value = data.get(self.entity_description.key)
else:
self._attr_native_value = None
+ self._update_data_valid()
+
+ def _update_data_valid(self) -> None:
+ self._data_valid = self._attr_native_value is not None and (
+ not self._numeric_state_expected
+ or isinstance(self._attr_native_value, (int, float))
+ or isinstance(self._attr_native_value, str)
+ and self._attr_native_value.isnumeric()
+ )
diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json
index e36f6a1ca8787f..a38ea4f7cfbd92 100644
--- a/homeassistant/components/google_assistant/manifest.json
+++ b/homeassistant/components/google_assistant/manifest.json
@@ -5,5 +5,6 @@
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/google_assistant",
+ "integration_type": "system",
"iot_class": "cloud_push"
}
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
index e54684fbc648c6..145eb4b2935628 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -294,7 +294,7 @@ def __init__(self, hass: HomeAssistant, state, config) -> None:
self.state = state
self.config = config
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
raise NotImplementedError
@@ -302,7 +302,7 @@ def sync_options(self) -> dict[str, Any]:
"""Add options for the sync request."""
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
raise NotImplementedError
@@ -337,11 +337,11 @@ def supported(domain, features, device_class, attributes):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return brightness attributes for a sync request."""
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return brightness query attributes."""
domain = self.state.domain
response = {}
@@ -388,7 +388,7 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return stream attributes for a sync request."""
return {
"cameraStreamSupportedProtocols": ["hls"],
@@ -396,7 +396,7 @@ def sync_attributes(self):
"cameraStreamNeedDrmEncryption": False,
}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return camera stream attributes."""
return self.stream_info or {}
@@ -426,7 +426,7 @@ def supported(domain, features, device_class, _) -> bool:
domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return ObjectDetection attributes for a sync request."""
return {}
@@ -434,7 +434,7 @@ def sync_options(self) -> dict[str, Any]:
"""Add options for the sync request."""
return {"notificationSupportedByAgent": True}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return ObjectDetection query attributes."""
return {}
@@ -498,13 +498,13 @@ def supported(domain, features, device_class, _):
humidifier.DOMAIN,
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return OnOff attributes for a sync request."""
if self.state.attributes.get(ATTR_ASSUMED_STATE, False):
return {"commandOnlyOnOff": True}
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return OnOff query attributes."""
return {"on": self.state.state not in (STATE_OFF, STATE_UNKNOWN)}
@@ -548,11 +548,11 @@ def supported(domain, features, device_class, attributes):
color_modes
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return color temperature attributes for a sync request."""
attrs = self.state.attributes
color_modes = attrs.get(light.ATTR_SUPPORTED_COLOR_MODES)
- response = {}
+ response: dict[str, Any] = {}
if light.color_supported(color_modes):
response["colorModel"] = "hsv"
@@ -571,11 +571,11 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return color temperature query attributes."""
color_mode = self.state.attributes.get(light.ATTR_COLOR_MODE)
- color = {}
+ color: dict[str, Any] = {}
if light.color_supported([color_mode]):
color_hs = self.state.attributes.get(light.ATTR_HS_COLOR)
@@ -684,12 +684,12 @@ def supported(domain, features, device_class, _):
script.DOMAIN,
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return scene attributes for a sync request."""
# None of the supported domains can support sceneReversible
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return scene query attributes."""
return {}
@@ -728,11 +728,11 @@ def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == vacuum.DOMAIN
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return dock attributes for a sync request."""
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return dock query attributes."""
return {"isDocked": self.state.state == vacuum.STATE_DOCKED}
@@ -762,11 +762,11 @@ def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == vacuum.DOMAIN and features & VacuumEntityFeature.LOCATE
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return locator attributes for a sync request."""
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return locator query attributes."""
return {}
@@ -802,14 +802,14 @@ def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == vacuum.DOMAIN and features & VacuumEntityFeature.BATTERY
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage attributes for a sync request."""
return {
"isRechargeable": True,
"queryOnlyEnergyStorage": True,
}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return EnergyStorage query attributes."""
battery_level = self.state.attributes.get(ATTR_BATTERY_LEVEL)
if battery_level is None:
@@ -866,7 +866,7 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return StartStop attributes for a sync request."""
domain = self.state.domain
if domain == vacuum.DOMAIN:
@@ -878,7 +878,9 @@ def sync_attributes(self):
if domain in COVER_VALVE_DOMAINS:
return {}
- def query_attributes(self):
+ raise NotImplementedError(f"Unsupported domain {domain}")
+
+ def query_attributes(self) -> dict[str, Any]:
"""Return StartStop query attributes."""
domain = self.state.domain
state = self.state.state
@@ -898,13 +900,17 @@ def query_attributes(self):
)
}
+ raise NotImplementedError(f"Unsupported domain {domain}")
+
async def execute(self, command, data, params, challenge):
"""Execute a StartStop command."""
domain = self.state.domain
if domain == vacuum.DOMAIN:
- return await self._execute_vacuum(command, data, params, challenge)
+ await self._execute_vacuum(command, data, params, challenge)
+ return
if domain in COVER_VALVE_DOMAINS:
- return await self._execute_cover_or_valve(command, data, params, challenge)
+ await self._execute_cover_or_valve(command, data, params, challenge)
+ return
async def _execute_vacuum(self, command, data, params, challenge):
"""Execute a StartStop command."""
@@ -1006,7 +1012,7 @@ def supported(domain, features, device_class, _):
and device_class == sensor.SensorDeviceClass.TEMPERATURE
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return temperature attributes for a sync request."""
response = {}
domain = self.state.domain
@@ -1042,7 +1048,7 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return temperature states."""
response = {}
domain = self.state.domain
@@ -1168,7 +1174,7 @@ def climate_google_modes(self):
return modes
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return temperature point and modes attributes for a sync request."""
response = {}
attrs = self.state.attributes
@@ -1211,9 +1217,9 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return temperature point and modes query attributes."""
- response = {}
+ response: dict[str, Any] = {}
attrs = self.state.attributes
unit = self.hass.config.units.temperature_unit
@@ -1426,9 +1432,9 @@ def supported(domain, features, device_class, _):
and device_class == sensor.SensorDeviceClass.HUMIDITY
)
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return humidity attributes for a sync request."""
- response = {}
+ response: dict[str, Any] = {}
attrs = self.state.attributes
domain = self.state.domain
@@ -1449,7 +1455,7 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return humidity query attributes."""
response = {}
attrs = self.state.attributes
@@ -1458,9 +1464,9 @@ def query_attributes(self):
if domain == sensor.DOMAIN:
device_class = attrs.get(ATTR_DEVICE_CLASS)
if device_class == sensor.SensorDeviceClass.HUMIDITY:
- current_humidity = self.state.state
- if current_humidity not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
- response["humidityAmbientPercent"] = round(float(current_humidity))
+ humidity_state = self.state.state
+ if humidity_state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ response["humidityAmbientPercent"] = round(float(humidity_state))
elif domain == humidifier.DOMAIN:
target_humidity: int | None = attrs.get(humidifier.ATTR_HUMIDITY)
@@ -1512,11 +1518,11 @@ def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return True
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return LockUnlock attributes for a sync request."""
return {}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return LockUnlock query attributes."""
if self.state.state == STATE_JAMMED:
return {"isJammed": True}
@@ -1598,7 +1604,7 @@ def _default_arm_state(self):
return states[0]
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return ArmDisarm attributes for a sync request."""
response = {}
levels = []
@@ -1618,7 +1624,7 @@ def sync_attributes(self):
response["availableArmLevels"] = {"levels": levels, "ordered": True}
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return ArmDisarm query attributes."""
armed_state = self.state.attributes.get("next_state", self.state.state)
@@ -1715,11 +1721,11 @@ def supported(domain, features, device_class, _):
return features & ClimateEntityFeature.FAN_MODE
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return speed point and modes attributes for a sync request."""
domain = self.state.domain
speeds = []
- result = {}
+ result: dict[str, Any] = {}
if domain == fan.DOMAIN:
reversible = bool(
@@ -1764,7 +1770,7 @@ def sync_attributes(self):
return result
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return speed point and modes query attributes."""
attrs = self.state.attributes
@@ -1910,7 +1916,7 @@ def _generate(self, name, settings):
)
return mode
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return mode attributes for a sync request."""
modes = []
@@ -1934,10 +1940,10 @@ def sync_attributes(self):
return {"availableModes": modes}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return current modes."""
attrs = self.state.attributes
- response = {}
+ response: dict[str, Any] = {}
mode_settings = {}
if self.state.domain == fan.DOMAIN:
@@ -2098,7 +2104,7 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return mode attributes for a sync request."""
attrs = self.state.attributes
sourcelist: list[str] = attrs.get(media_player.ATTR_INPUT_SOURCE_LIST) or []
@@ -2109,7 +2115,7 @@ def sync_attributes(self):
return {"availableInputs": inputs, "orderedInputs": True}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return current modes."""
attrs = self.state.attributes
return {"currentInput": attrs.get(media_player.ATTR_INPUT_SOURCE, "")}
@@ -2179,7 +2185,7 @@ def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return domain == cover.DOMAIN and device_class in OpenCloseTrait.COVER_2FA
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return opening direction."""
response = {}
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -2215,10 +2221,10 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return state query attributes."""
domain = self.state.domain
- response = {}
+ response: dict[str, Any] = {}
# When it's an assumed state, we will return empty state
# This shouldn't happen because we set `commandOnlyOpenClose`
@@ -2324,7 +2330,7 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return volume attributes for a sync request."""
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return {
@@ -2341,7 +2347,7 @@ def sync_attributes(self):
"levelStepSize": 10,
}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return volume query attributes."""
response = {}
@@ -2504,7 +2510,7 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return opening direction."""
response = {}
@@ -2519,7 +2525,7 @@ def sync_attributes(self):
return response
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
return {}
@@ -2618,11 +2624,11 @@ def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == media_player.DOMAIN
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
return {"supportActivityState": True, "supportPlaybackState": True}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
return {
"activityState": self.activity_lookup.get(self.state.state, "INACTIVE"),
@@ -2652,11 +2658,11 @@ def supported(domain, features, device_class, _):
return False
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
return {"availableChannels": [], "commandOnlyChannels": True}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return channel query attributes."""
return {}
@@ -2729,7 +2735,7 @@ def supported(cls, domain, features, device_class, _):
"""Test if state is supported."""
return domain == sensor.DOMAIN and device_class in cls.sensor_types
- def sync_attributes(self):
+ def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
@@ -2757,7 +2763,7 @@ def sync_attributes(self):
return {"sensorStatesSupported": [sensor_state]}
- def query_attributes(self):
+ def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py
index 66dfbcf01eb9b3..8ae6a456a4ff64 100644
--- a/homeassistant/components/google_cloud/helpers.py
+++ b/homeassistant/components/google_cloud/helpers.py
@@ -59,7 +59,10 @@ def tts_options_schema(
vol.Optional(
CONF_GENDER,
description={"suggested_value": config_options.get(CONF_GENDER)},
- default=texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
+ default=config_options.get(
+ CONF_GENDER,
+ texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
+ ),
): vol.All(
vol.Upper,
SelectSelector(
@@ -72,7 +75,7 @@ def tts_options_schema(
vol.Optional(
CONF_VOICE,
description={"suggested_value": config_options.get(CONF_VOICE)},
- default=DEFAULT_VOICE,
+ default=config_options.get(CONF_VOICE, DEFAULT_VOICE),
): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN,
@@ -82,7 +85,10 @@ def tts_options_schema(
vol.Optional(
CONF_ENCODING,
description={"suggested_value": config_options.get(CONF_ENCODING)},
- default=texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
+ default=config_options.get(
+ CONF_ENCODING,
+ texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
+ ),
): vol.All(
vol.Upper,
SelectSelector(
@@ -95,22 +101,22 @@ def tts_options_schema(
vol.Optional(
CONF_SPEED,
description={"suggested_value": config_options.get(CONF_SPEED)},
- default=1.0,
+ default=config_options.get(CONF_SPEED, 1.0),
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
vol.Optional(
CONF_PITCH,
description={"suggested_value": config_options.get(CONF_PITCH)},
- default=0,
+ default=config_options.get(CONF_PITCH, 0),
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
vol.Optional(
CONF_GAIN,
description={"suggested_value": config_options.get(CONF_GAIN)},
- default=0,
+ default=config_options.get(CONF_GAIN, 0),
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
vol.Optional(
CONF_PROFILES,
description={"suggested_value": config_options.get(CONF_PROFILES)},
- default=[],
+ default=config_options.get(CONF_PROFILES, []),
): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN,
@@ -132,7 +138,7 @@ def tts_options_schema(
vol.Optional(
CONF_TEXT_TYPE,
description={"suggested_value": config_options.get(CONF_TEXT_TYPE)},
- default="text",
+ default=config_options.get(CONF_TEXT_TYPE, "text"),
): vol.All(
vol.Lower,
SelectSelector(
diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py
index 8dec62ad26b875..0d24ddbf39fe67 100644
--- a/homeassistant/components/google_generative_ai_conversation/conversation.py
+++ b/homeassistant/components/google_generative_ai_conversation/conversation.py
@@ -89,9 +89,9 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
key = "type_"
val = val.upper()
elif key == "format":
- if (schema.get("type") == "string" and val != "enum") or (
- schema.get("type") not in ("number", "integer", "string")
- ):
+ if schema.get("type") == "string" and val != "enum":
+ continue
+ if schema.get("type") not in ("number", "integer", "string"):
continue
key = "format_"
elif key == "items":
@@ -100,11 +100,19 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
+ if result.get("enum") and result.get("type_") != "STRING":
+ # enum is only allowed for STRING type. This is safe as long as the schema
+ # contains vol.Coerce for the respective type, for example:
+ # vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
+ result["type_"] = "STRING"
+ result["enum"] = [str(item) for item in result["enum"]]
+
if result.get("type_") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type_": "STRING"}}
+ result["required"] = []
return result
@@ -164,6 +172,10 @@ def __init__(self, entry: ConfigEntry) -> None:
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
+ if self.entry.options.get(CONF_LLM_HASS_API):
+ self._attr_supported_features = (
+ conversation.ConversationEntityFeature.CONTROL
+ )
@property
def supported_languages(self) -> list[str] | Literal["*"]:
@@ -177,6 +189,9 @@ async def async_added_to_hass(self) -> None:
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
+ self.entry.async_on_unload(
+ self.entry.add_update_listener(self._async_entry_update_listener)
+ )
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -286,6 +301,7 @@ async def async_process(
if supports_system_instruction
else messages[2:],
"prompt": prompt,
+ "tools": [*llm_api.tools] if llm_api else None,
},
)
@@ -396,3 +412,10 @@ async def _async_render_prompt(
parts.append(llm_api.api_prompt)
return "\n".join(parts)
+
+ async def _async_entry_update_listener(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Handle options update."""
+ # Reload as we update device info + entity name + supported features
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index 6c45033eeb7b9b..618dda50bd4aa2 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -8,7 +8,11 @@
from googlemaps import Client
from googlemaps.distance_matrix import distance_matrix
-from homeassistant.components.sensor import SensorEntity
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
@@ -72,6 +76,8 @@ class GoogleTravelTimeSensor(SensorEntity):
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
+ _attr_device_class = SensorDeviceClass.DURATION
+ _attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry, name, api_key, origin, destination, client):
"""Initialize the sensor."""
diff --git a/homeassistant/components/gpsd/config_flow.py b/homeassistant/components/gpsd/config_flow.py
index 59c95d0ddbfa79..ac41324f857ab4 100644
--- a/homeassistant/components/gpsd/config_flow.py
+++ b/homeassistant/components/gpsd/config_flow.py
@@ -39,10 +39,6 @@ def test_connection(host: str, port: int) -> bool:
else:
return True
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a config entry from configuration.yaml."""
- return await self.async_step_user(import_data)
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/gpsd/icons.json b/homeassistant/components/gpsd/icons.json
index b29640e0001c88..59d904f918c5ae 100644
--- a/homeassistant/components/gpsd/icons.json
+++ b/homeassistant/components/gpsd/icons.json
@@ -7,6 +7,15 @@
"2d_fix": "mdi:crosshairs-gps",
"3d_fix": "mdi:crosshairs-gps"
}
+ },
+ "latitude": {
+ "default": "mdi:latitude"
+ },
+ "longitude": {
+ "default": "mdi:longitude"
+ },
+ "elevation": {
+ "default": "mdi:arrow-up-down"
}
}
}
diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py
index e67287ae134bb7..1bac41ecaaead9 100644
--- a/homeassistant/components/gpsd/sensor.py
+++ b/homeassistant/components/gpsd/sensor.py
@@ -4,38 +4,31 @@
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
import logging
from typing import Any
-from gps3.agps3threaded import (
- GPSD_PORT as DEFAULT_PORT,
- HOST as DEFAULT_HOST,
- AGPS3mechanism,
-)
-import voluptuous as vol
+from gps3.agps3threaded import AGPS3mechanism
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_MODE,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
+ ATTR_TIME,
EntityCategory,
+ UnitOfLength,
+ UnitOfSpeed,
)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
from . import GPSDConfigEntry
from .const import DOMAIN
@@ -56,27 +49,73 @@
class GpsdSensorDescription(SensorEntityDescription):
"""Class describing GPSD sensor entities."""
- value_fn: Callable[[AGPS3mechanism], str | None]
+ value_fn: Callable[[AGPS3mechanism], StateType | datetime]
SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = (
GpsdSensorDescription(
- key="mode",
- translation_key="mode",
+ key=ATTR_MODE,
+ translation_key=ATTR_MODE,
name=None,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=list(_MODE_VALUES.values()),
value_fn=lambda agps_thread: _MODE_VALUES.get(agps_thread.data_stream.mode),
),
-)
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }
+ GpsdSensorDescription(
+ key=ATTR_LATITUDE,
+ translation_key=ATTR_LATITUDE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda agps_thread: agps_thread.data_stream.lat,
+ entity_registry_enabled_default=False,
+ ),
+ GpsdSensorDescription(
+ key=ATTR_LONGITUDE,
+ translation_key=ATTR_LONGITUDE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda agps_thread: agps_thread.data_stream.lon,
+ entity_registry_enabled_default=False,
+ ),
+ GpsdSensorDescription(
+ key=ATTR_ELEVATION,
+ translation_key=ATTR_ELEVATION,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.METERS,
+ value_fn=lambda agps_thread: agps_thread.data_stream.alt,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ ),
+ GpsdSensorDescription(
+ key=ATTR_TIME,
+ translation_key=ATTR_TIME,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=lambda agps_thread: dt_util.parse_datetime(
+ agps_thread.data_stream.time
+ ),
+ entity_registry_enabled_default=False,
+ ),
+ GpsdSensorDescription(
+ key=ATTR_SPEED,
+ translation_key=ATTR_SPEED,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.SPEED,
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ value_fn=lambda agps_thread: agps_thread.data_stream.speed,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ ),
+ GpsdSensorDescription(
+ key=ATTR_CLIMB,
+ translation_key=ATTR_CLIMB,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.SPEED,
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ value_fn=lambda agps_thread: agps_thread.data_stream.climb,
+ suggested_display_precision=2,
+ entity_registry_enabled_default=False,
+ ),
)
@@ -98,34 +137,6 @@ async def async_setup_entry(
)
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Initialize gpsd import from config."""
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- breaks_in_ha_version="2024.9.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "GPSD",
- },
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
- )
-
-
class GpsdSensor(SensorEntity):
"""Representation of a GPS receiver available via GPSD."""
@@ -150,13 +161,19 @@ def __init__(
self.agps_thread = agps_thread
@property
- def native_value(self) -> str | None:
+ def native_value(self) -> StateType | datetime:
"""Return the state of GPSD."""
- return self.entity_description.value_fn(self.agps_thread)
+ value = self.entity_description.value_fn(self.agps_thread)
+ return None if value == "n/a" else value
+ # Deprecated since Home Assistant 2024.9.0
+ # Can be removed completely in 2025.3.0
@property
- def extra_state_attributes(self) -> dict[str, Any]:
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the GPS."""
+ if self.entity_description.key != ATTR_MODE:
+ return None
+
return {
ATTR_LATITUDE: self.agps_thread.data_stream.lat,
ATTR_LONGITUDE: self.agps_thread.data_stream.lon,
diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json
index 20dc283a8bb45d..867edf0b5a8a22 100644
--- a/homeassistant/components/gpsd/strings.json
+++ b/homeassistant/components/gpsd/strings.json
@@ -18,7 +18,15 @@
},
"entity": {
"sensor": {
+ "latitude": { "name": "[%key:common::config_flow::data::latitude%]" },
+ "longitude": { "name": "[%key:common::config_flow::data::longitude%]" },
+ "elevation": { "name": "[%key:common::config_flow::data::elevation%]" },
+ "time": {
+ "name": "[%key:component::time_date::selector::display_options::options::time%]"
+ },
+ "climb": { "name": "Climb" },
"mode": {
+ "name": "[%key:common::config_flow::data::mode%]",
"state": {
"2d_fix": "2D Fix",
"3d_fix": "3D Fix"
@@ -28,11 +36,19 @@
"longitude": {
"name": "[%key:common::config_flow::data::longitude%]"
},
- "elevation": { "name": "Elevation" },
- "gps_time": { "name": "Time" },
- "speed": { "name": "Speed" },
- "climb": { "name": "Climb" },
- "mode": { "name": "Mode" }
+ "elevation": {
+ "name": "[%key:common::config_flow::data::elevation%]"
+ },
+ "gps_time": {
+ "name": "[%key:component::time_date::selector::display_options::options::time%]"
+ },
+ "speed": {
+ "name": "[%key:component::sensor::entity_component::speed::name%]"
+ },
+ "climb": {
+ "name": "[%key:component::gpsd::entity::sensor::climb::name%]"
+ },
+ "mode": { "name": "[%key:common::config_flow::data::mode%]" }
}
}
}
diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py
index 46479210921411..f926eb1c53ea3c 100644
--- a/homeassistant/components/gree/const.py
+++ b/homeassistant/components/gree/const.py
@@ -18,3 +18,5 @@
MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
+
+UPDATE_INTERVAL = 60
diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py
index 1bccf3bbc484e5..ae8b22706ef43c 100644
--- a/homeassistant/components/gree/coordinator.py
+++ b/homeassistant/components/gree/coordinator.py
@@ -2,16 +2,20 @@
from __future__ import annotations
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
+from typing import Any
from greeclimate.device import Device, DeviceInfo
from greeclimate.discovery import Discovery, Listener
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
+from greeclimate.network import Response
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.dt import utcnow
from .const import (
COORDINATORS,
@@ -19,12 +23,13 @@
DISPATCH_DEVICE_DISCOVERED,
DOMAIN,
MAX_ERRORS,
+ UPDATE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
-class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
+class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages polling for state changes from the device."""
def __init__(self, hass: HomeAssistant, device: Device) -> None:
@@ -34,28 +39,68 @@ def __init__(self, hass: HomeAssistant, device: Device) -> None:
hass,
_LOGGER,
name=f"{DOMAIN}-{device.device_info.name}",
- update_interval=timedelta(seconds=60),
+ update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ always_update=False,
)
self.device = device
+ self.device.add_handler(Response.DATA, self.device_state_updated)
+ self.device.add_handler(Response.RESULT, self.device_state_updated)
+
+ self._error_count: int = 0
+ self._last_response_time: datetime = utcnow()
+ self._last_error_time: datetime | None = None
+
+ def device_state_updated(self, *args: Any) -> None:
+ """Handle device state updates."""
+ _LOGGER.debug("Device state updated: %s", json_dumps(args))
self._error_count = 0
+ self._last_response_time = utcnow()
+ self.async_set_updated_data(self.device.raw_properties)
- async def _async_update_data(self):
+ async def _async_update_data(self) -> dict[str, Any]:
"""Update the state of the device."""
+ _LOGGER.debug(
+ "Updating device state: %s, error count: %d", self.name, self._error_count
+ )
try:
await self.device.update_state()
except DeviceNotBoundError as error:
- raise UpdateFailed(f"Device {self.name} is unavailable") from error
+ raise UpdateFailed(
+ f"Device {self.name} is unavailable, device is not bound."
+ ) from error
except DeviceTimeoutError as error:
self._error_count += 1
# Under normal conditions GREE units timeout every once in a while
if self.last_update_success and self._error_count >= MAX_ERRORS:
_LOGGER.warning(
- "Device is unavailable: %s (%s)",
+ "Device %s is unavailable: %s", self.name, self.device.device_info
+ )
+ raise UpdateFailed(
+ f"Device {self.name} is unavailable, could not send update request"
+ ) from error
+ else:
+ # raise update failed if time for more than MAX_ERRORS has passed since last update
+ now = utcnow()
+ elapsed_success = now - self._last_response_time
+ if self.update_interval and elapsed_success >= self.update_interval:
+ if not self._last_error_time or (
+ (now - self.update_interval) >= self._last_error_time
+ ):
+ self._last_error_time = now
+ self._error_count += 1
+
+ _LOGGER.warning(
+ "Device %s is unresponsive for %s seconds",
self.name,
- self.device.device_info,
+ elapsed_success,
+ )
+ if self.last_update_success and self._error_count >= MAX_ERRORS:
+ raise UpdateFailed(
+ f"Device {self.name} is unresponsive for too long and now unavailable"
)
- raise UpdateFailed(f"Device {self.name} is unavailable") from error
+
+ return self.device.raw_properties
async def push_state_update(self):
"""Send state updates to the physical device."""
diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json
index a7c884c4042dee..dba8cd6077c9d3 100644
--- a/homeassistant/components/gree/manifest.json
+++ b/homeassistant/components/gree/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
- "requirements": ["greeclimate==1.4.6"]
+ "requirements": ["greeclimate==2.1.0"]
}
diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py
index 8294b55be5effe..ecbfec0bdb8167 100644
--- a/homeassistant/components/group/notify.py
+++ b/homeassistant/components/group/notify.py
@@ -22,8 +22,9 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
- ATTR_SERVICE,
+ CONF_ACTION,
CONF_ENTITIES,
+ CONF_SERVICE,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant, callback
@@ -36,11 +37,37 @@
CONF_SERVICES = "services"
+
+def _backward_compat_schema(value: Any | None) -> Any:
+ """Backward compatibility for notify service schemas."""
+
+ if not isinstance(value, dict):
+ return value
+
+ # `service` has been renamed to `action`
+ if CONF_SERVICE in value:
+ if CONF_ACTION in value:
+ raise vol.Invalid(
+ "Cannot specify both 'service' and 'action'. Please use 'action' only."
+ )
+ value[CONF_ACTION] = value.pop(CONF_SERVICE)
+
+ return value
+
+
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_SERVICES): vol.All(
cv.ensure_list,
- [{vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict}],
+ [
+ vol.All(
+ _backward_compat_schema,
+ {
+ vol.Required(CONF_ACTION): cv.slug,
+ vol.Optional(ATTR_DATA): dict,
+ },
+ )
+ ],
)
}
)
@@ -88,7 +115,7 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
tasks.append(
asyncio.create_task(
self.hass.services.async_call(
- DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True
+ DOMAIN, entity[CONF_ACTION], sending_payload, blocking=True
)
)
)
diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py
index eaaedcf0e46f68..a99ed9dad639d5 100644
--- a/homeassistant/components/group/sensor.py
+++ b/homeassistant/components/group/sensor.py
@@ -406,7 +406,7 @@ def async_update_group_state(self) -> None:
and (uom := state.attributes["unit_of_measurement"])
not in self._valid_units
):
- raise HomeAssistantError("Not a valid unit")
+ raise HomeAssistantError("Not a valid unit") # noqa: TRY301
sensor_values.append((entity_id, numeric_state, state))
if entity_id in self._state_incorrect:
diff --git a/homeassistant/components/growatt_server/sensor_types/tlx.py b/homeassistant/components/growatt_server/sensor_types/tlx.py
index d8f158f24217dc..bf8746e08aca8a 100644
--- a/homeassistant/components/growatt_server/sensor_types/tlx.py
+++ b/homeassistant/components/growatt_server/sensor_types/tlx.py
@@ -327,14 +327,14 @@
GrowattSensorEntityDescription(
key="tlx_battery_2_discharge_w",
translation_key="tlx_battery_2_discharge_w",
- api_key="bdc1DischargePower",
+ api_key="bdc2DischargePower",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
),
GrowattSensorEntityDescription(
key="tlx_battery_2_discharge_total",
translation_key="tlx_battery_2_discharge_total",
- api_key="bdc1DischargeTotal",
+ api_key="bdc2DischargeTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -376,14 +376,14 @@
GrowattSensorEntityDescription(
key="tlx_battery_2_charge_w",
translation_key="tlx_battery_2_charge_w",
- api_key="bdc1ChargePower",
+ api_key="bdc2ChargePower",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
),
GrowattSensorEntityDescription(
key="tlx_battery_2_charge_total",
translation_key="tlx_battery_2_charge_total",
- api_key="bdc1ChargeTotal",
+ api_key="bdc2ChargeTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py
index cdd166a44445ed..276aa4e7fc063a 100644
--- a/homeassistant/components/habitica/button.py
+++ b/homeassistant/components/habitica/button.py
@@ -113,7 +113,7 @@ async def async_press(self) -> None:
translation_key="service_call_exception",
) from e
else:
- await self.coordinator.async_refresh()
+ await self.coordinator.async_request_refresh()
@property
def available(self) -> bool:
diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py
index 9d0ebe651e3f8d..4e949b703fb3e7 100644
--- a/homeassistant/components/habitica/coordinator.py
+++ b/homeassistant/components/habitica/coordinator.py
@@ -15,6 +15,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -41,7 +42,13 @@ def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
hass,
_LOGGER,
name=DOMAIN,
- update_interval=timedelta(seconds=30),
+ update_interval=timedelta(seconds=60),
+ request_refresh_debouncer=Debouncer(
+ hass,
+ _LOGGER,
+ cooldown=5,
+ immediate=False,
+ ),
)
self.api = habitipy
@@ -51,6 +58,9 @@ async def _async_update_data(self) -> HabiticaData:
tasks_response = await self.api.tasks.user.get()
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
except ClientResponseError as error:
+ if error.status == HTTPStatus.TOO_MANY_REQUESTS:
+ _LOGGER.debug("Currently rate limited, skipping update")
+ return self.data
raise UpdateFailed(f"Error communicating with API: {error}") from error
return HabiticaData(user=user_response, tasks=tasks_response)
@@ -73,4 +83,4 @@ async def execute(
translation_key="service_call_exception",
) from e
else:
- await self.async_refresh()
+ await self.async_request_refresh()
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index 5696e6f99111b4..21d2622245c837 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -100,7 +100,10 @@
},
"exceptions": {
"delete_todos_failed": {
- "message": "Unable to delete {count} Habitica to-do(s), please try again"
+ "message": "Unable to delete item from Habitica to-do list, please try again"
+ },
+ "delete_completed_todos_failed": {
+ "message": "Unable to delete completed to-do items from Habitica to-do list, please try again"
},
"move_todos_item_failed": {
"message": "Unable to move the Habitica to-do to position {pos}, please try again"
diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py
index ab458f9f59f2d1..ae739d4726295e 100644
--- a/homeassistant/components/habitica/todo.py
+++ b/homeassistant/components/habitica/todo.py
@@ -75,16 +75,25 @@ def __init__(
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete Habitica tasks."""
- for task_id in uids:
+ if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try:
- await self.coordinator.api.tasks[task_id].delete()
+ await self.coordinator.api.tasks.clearCompletedTodos.post()
except ClientResponseError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
- translation_key=f"delete_{self.entity_description.key}_failed",
+ translation_key="delete_completed_todos_failed",
) from e
+ else:
+ for task_id in uids:
+ try:
+ await self.coordinator.api.tasks[task_id].delete()
+ except ClientResponseError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key=f"delete_{self.entity_description.key}_failed",
+ ) from e
- await self.coordinator.async_refresh()
+ await self.coordinator.async_request_refresh()
async def async_move_todo_item(
self, uid: str, previous_uid: str | None = None
@@ -112,9 +121,22 @@ async def async_move_todo_item(
translation_key=f"move_{self.entity_description.key}_item_failed",
translation_placeholders={"pos": str(pos)},
) from e
+ else:
+ # move tasks in the coordinator until we have fresh data
+ tasks = self.coordinator.data.tasks
+ new_pos = (
+ tasks.index(next(task for task in tasks if task["id"] == previous_uid))
+ + 1
+ if previous_uid
+ else 0
+ )
+ old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
+ tasks.insert(new_pos, tasks.pop(old_pos))
+ await self.coordinator.async_request_refresh()
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a Habitica todo."""
+ refresh_required = False
current_item = next(
(task for task in (self.todo_items or []) if task.uid == item.uid),
None,
@@ -123,7 +145,6 @@ async def async_update_todo_item(self, item: TodoItem) -> None:
if TYPE_CHECKING:
assert item.uid
assert current_item
- assert item.due
if (
self.entity_description.key is HabiticaTodoList.TODOS
@@ -133,18 +154,24 @@ async def async_update_todo_item(self, item: TodoItem) -> None:
else:
date = None
- try:
- await self.coordinator.api.tasks[item.uid].put(
- text=item.summary,
- notes=item.description or "",
- date=date,
- )
- except ClientResponseError as e:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key=f"update_{self.entity_description.key}_item_failed",
- translation_placeholders={"name": item.summary or ""},
- ) from e
+ if (
+ item.summary != current_item.summary
+ or item.description != current_item.description
+ or item.due != current_item.due
+ ):
+ try:
+ await self.coordinator.api.tasks[item.uid].put(
+ text=item.summary,
+ notes=item.description or "",
+ date=date,
+ )
+ refresh_required = True
+ except ClientResponseError as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key=f"update_{self.entity_description.key}_item_failed",
+ translation_placeholders={"name": item.summary or ""},
+ ) from e
try:
# Score up or down if item status changed
@@ -155,6 +182,7 @@ async def async_update_todo_item(self, item: TodoItem) -> None:
score_result = (
await self.coordinator.api.tasks[item.uid].score["up"].post()
)
+ refresh_required = True
elif (
current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION
@@ -162,6 +190,7 @@ async def async_update_todo_item(self, item: TodoItem) -> None:
score_result = (
await self.coordinator.api.tasks[item.uid].score["down"].post()
)
+ refresh_required = True
else:
score_result = None
@@ -180,8 +209,8 @@ async def async_update_todo_item(self, item: TodoItem) -> None:
persistent_notification.async_create(
self.hass, message=msg, title="Habitica"
)
-
- await self.coordinator.async_refresh()
+ if refresh_required:
+ await self.coordinator.async_request_refresh()
class HabiticaTodosListEntity(BaseHabiticaListEntity):
@@ -245,7 +274,7 @@ async def async_create_todo_item(self, item: TodoItem) -> None:
translation_placeholders={"name": item.summary or ""},
) from e
- await self.coordinator.async_refresh()
+ await self.coordinator.async_request_refresh()
class HabiticaDailiesListEntity(BaseHabiticaListEntity):
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index d30aa4759446f3..efbd4b2ac02631 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -75,7 +75,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_SYNC,
- {},
+ None,
"sync",
)
platform.async_register_entity_service(
diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py
index 836e68abe9f6fd..fbb6a6b48f9bf5 100644
--- a/homeassistant/components/hddtemp/sensor.py
+++ b/homeassistant/components/hddtemp/sensor.py
@@ -6,6 +6,7 @@
import logging
import socket
from telnetlib import Telnet # pylint: disable=deprecated-module
+from typing import Any
import voluptuous as vol
@@ -82,10 +83,11 @@ def __init__(self, name, disk, hddtemp):
self._details = None
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the sensor."""
if self._details is not None:
return {ATTR_DEVICE: self._details[0], ATTR_MODEL: self._details[1]}
+ return None
def update(self) -> None:
"""Get the latest data from HDDTemp daemon and updates the state."""
diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py
index 36d5c1efe1e044..b708fd9cd3df9c 100644
--- a/homeassistant/components/here_travel_time/config_flow.py
+++ b/homeassistant/components/here_travel_time/config_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from here_routing import (
HERERoutingApi,
@@ -104,6 +104,8 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Init Config Flow."""
self._config: dict[str, Any] = {}
+ self._entry: ConfigEntry | None = None
+ self._is_reconfigure_flow: bool = False
@staticmethod
@callback
@@ -119,21 +121,36 @@ async def async_step_user(
"""Handle the initial step."""
errors = {}
user_input = user_input or {}
- if user_input:
- try:
- await async_validate_api_key(user_input[CONF_API_KEY])
- except HERERoutingUnauthorizedError:
- errors["base"] = "invalid_auth"
- except (HERERoutingError, HERETransitError):
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
- if not errors:
- self._config = user_input
- return await self.async_step_origin_menu()
+ if not self._is_reconfigure_flow: # Always show form first for reconfiguration
+ if user_input:
+ try:
+ await async_validate_api_key(user_input[CONF_API_KEY])
+ except HERERoutingUnauthorizedError:
+ errors["base"] = "invalid_auth"
+ except (HERERoutingError, HERETransitError):
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ if not errors:
+ self._config[CONF_NAME] = user_input[CONF_NAME]
+ self._config[CONF_API_KEY] = user_input[CONF_API_KEY]
+ self._config[CONF_MODE] = user_input[CONF_MODE]
+ return await self.async_step_origin_menu()
+ self._is_reconfigure_flow = False
return self.async_show_form(
step_id="user", data_schema=get_user_step_schema(user_input), errors=errors
)
+ async def async_step_reconfigure(
+ self, _: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration."""
+ self._is_reconfigure_flow = True
+ self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+ if TYPE_CHECKING:
+ assert self._entry
+ self._config = self._entry.data.copy()
+ return await self.async_step_user(self._config)
+
async def async_step_origin_menu(self, _: None = None) -> ConfigFlowResult:
"""Show the origin menu."""
return self.async_show_menu(
@@ -150,37 +167,57 @@ async def async_step_origin_coordinates(
self._config[CONF_ORIGIN_LONGITUDE] = user_input[CONF_ORIGIN][
CONF_LONGITUDE
]
+ # Remove possible previous configuration using an entity_id
+ self._config.pop(CONF_ORIGIN_ENTITY_ID, None)
return await self.async_step_destination_menu()
- schema = vol.Schema(
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_ORIGIN,
+ ): LocationSelector()
+ }
+ ),
{
- vol.Required(
- CONF_ORIGIN,
- default={
- CONF_LATITUDE: self.hass.config.latitude,
- CONF_LONGITUDE: self.hass.config.longitude,
- },
- ): LocationSelector()
- }
+ CONF_ORIGIN: {
+ CONF_LATITUDE: self._config.get(CONF_ORIGIN_LATITUDE)
+ or self.hass.config.latitude,
+ CONF_LONGITUDE: self._config.get(CONF_ORIGIN_LONGITUDE)
+ or self.hass.config.longitude,
+ }
+ },
)
return self.async_show_form(step_id="origin_coordinates", data_schema=schema)
- async def async_step_destination_menu(self, _: None = None) -> ConfigFlowResult:
- """Show the destination menu."""
- return self.async_show_menu(
- step_id="destination_menu",
- menu_options=["destination_coordinates", "destination_entity"],
- )
-
async def async_step_origin_entity(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure origin by using an entity."""
if user_input is not None:
self._config[CONF_ORIGIN_ENTITY_ID] = user_input[CONF_ORIGIN_ENTITY_ID]
+ # Remove possible previous configuration using coordinates
+ self._config.pop(CONF_ORIGIN_LATITUDE, None)
+ self._config.pop(CONF_ORIGIN_LONGITUDE, None)
return await self.async_step_destination_menu()
- schema = vol.Schema({vol.Required(CONF_ORIGIN_ENTITY_ID): EntitySelector()})
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_ORIGIN_ENTITY_ID,
+ ): EntitySelector()
+ }
+ ),
+ {CONF_ORIGIN_ENTITY_ID: self._config.get(CONF_ORIGIN_ENTITY_ID)},
+ )
return self.async_show_form(step_id="origin_entity", data_schema=schema)
+ async def async_step_destination_menu(self, _: None = None) -> ConfigFlowResult:
+ """Show the destination menu."""
+ return self.async_show_menu(
+ step_id="destination_menu",
+ menu_options=["destination_coordinates", "destination_entity"],
+ )
+
async def async_step_destination_coordinates(
self,
user_input: dict[str, Any] | None = None,
@@ -193,21 +230,36 @@ async def async_step_destination_coordinates(
self._config[CONF_DESTINATION_LONGITUDE] = user_input[CONF_DESTINATION][
CONF_LONGITUDE
]
+ # Remove possible previous configuration using an entity_id
+ self._config.pop(CONF_DESTINATION_ENTITY_ID, None)
+ if self._entry:
+ return self.async_update_reload_and_abort(
+ self._entry,
+ title=self._config[CONF_NAME],
+ data=self._config,
+ reason="reconfigure_successful",
+ )
return self.async_create_entry(
title=self._config[CONF_NAME],
data=self._config,
options=DEFAULT_OPTIONS,
)
- schema = vol.Schema(
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_DESTINATION,
+ ): LocationSelector()
+ }
+ ),
{
- vol.Required(
- CONF_DESTINATION,
- default={
- CONF_LATITUDE: self.hass.config.latitude,
- CONF_LONGITUDE: self.hass.config.longitude,
- },
- ): LocationSelector()
- }
+ CONF_DESTINATION: {
+ CONF_LATITUDE: self._config.get(CONF_DESTINATION_LATITUDE)
+ or self.hass.config.latitude,
+ CONF_LONGITUDE: self._config.get(CONF_DESTINATION_LONGITUDE)
+ or self.hass.config.longitude,
+ },
+ },
)
return self.async_show_form(
step_id="destination_coordinates", data_schema=schema
@@ -222,13 +274,27 @@ async def async_step_destination_entity(
self._config[CONF_DESTINATION_ENTITY_ID] = user_input[
CONF_DESTINATION_ENTITY_ID
]
+ # Remove possible previous configuration using coordinates
+ self._config.pop(CONF_DESTINATION_LATITUDE, None)
+ self._config.pop(CONF_DESTINATION_LONGITUDE, None)
+ if self._entry:
+ return self.async_update_reload_and_abort(
+ self._entry, data=self._config, reason="reconfigure_successful"
+ )
return self.async_create_entry(
title=self._config[CONF_NAME],
data=self._config,
options=DEFAULT_OPTIONS,
)
- schema = vol.Schema(
- {vol.Required(CONF_DESTINATION_ENTITY_ID): EntitySelector()}
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_DESTINATION_ENTITY_ID,
+ ): EntitySelector()
+ }
+ ),
+ {CONF_DESTINATION_ENTITY_ID: self._config.get(CONF_DESTINATION_ENTITY_ID)},
)
return self.async_show_form(step_id="destination_entity", data_schema=schema)
@@ -249,15 +315,22 @@ async def async_step_init(
self._config = user_input
return await self.async_step_time_menu()
- schema = vol.Schema(
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Optional(
+ CONF_ROUTE_MODE,
+ default=self.config_entry.options.get(
+ CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
+ ),
+ ): vol.In(ROUTE_MODES),
+ }
+ ),
{
- vol.Optional(
- CONF_ROUTE_MODE,
- default=self.config_entry.options.get(
- CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
- ),
- ): vol.In(ROUTE_MODES),
- }
+ CONF_ROUTE_MODE: self.config_entry.options.get(
+ CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
+ ),
+ },
)
return self.async_show_form(step_id="init", data_schema=schema)
@@ -283,8 +356,11 @@ async def async_step_arrival_time(
self._config[CONF_ARRIVAL_TIME] = user_input[CONF_ARRIVAL_TIME]
return self.async_create_entry(title="", data=self._config)
- schema = vol.Schema(
- {vol.Required(CONF_ARRIVAL_TIME, default="00:00:00"): TimeSelector()}
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {vol.Required(CONF_ARRIVAL_TIME, default="00:00:00"): TimeSelector()}
+ ),
+ {CONF_ARRIVAL_TIME: "00:00:00"},
)
return self.async_show_form(step_id="arrival_time", data_schema=schema)
@@ -297,8 +373,11 @@ async def async_step_departure_time(
self._config[CONF_DEPARTURE_TIME] = user_input[CONF_DEPARTURE_TIME]
return self.async_create_entry(title="", data=self._config)
- schema = vol.Schema(
- {vol.Required(CONF_DEPARTURE_TIME, default="00:00:00"): TimeSelector()}
+ schema = self.add_suggested_values_to_schema(
+ vol.Schema(
+ {vol.Required(CONF_DEPARTURE_TIME, default="00:00:00"): TimeSelector()}
+ ),
+ {CONF_DEPARTURE_TIME: "00:00:00"},
)
return self.async_show_form(step_id="departure_time", data_schema=schema)
diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json
index 124aa070595909..cfa14a3e3caa03 100644
--- a/homeassistant/components/here_travel_time/strings.json
+++ b/homeassistant/components/here_travel_time/strings.json
@@ -52,7 +52,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"options": {
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
index 99e953ff9ddab2..4558da8722c24b 100644
--- a/homeassistant/components/history_stats/sensor.py
+++ b/homeassistant/components/history_stats/sensor.py
@@ -103,10 +103,6 @@ async def async_setup_platform(
name: str = config[CONF_NAME]
unique_id: str | None = config.get(CONF_UNIQUE_ID)
- for template in (start, end):
- if template is not None:
- template.hass = hass
-
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, name)
await coordinator.async_refresh()
diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py
index 68d93e9719d1f3..61199e4b2f7f3f 100644
--- a/homeassistant/components/hitron_coda/device_tracker.py
+++ b/homeassistant/components/hitron_coda/device_tracker.py
@@ -42,7 +42,7 @@ def get_scanner(
return scanner if scanner.success_init else None
-Device = namedtuple("Device", ["mac", "name"])
+Device = namedtuple("Device", ["mac", "name"]) # noqa: PYI024
class HitronCODADeviceScanner(DeviceScanner):
diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py
index f4c8e6787028ff..87d93eea95fd27 100644
--- a/homeassistant/components/hive/climate.py
+++ b/homeassistant/components/hive/climate.py
@@ -83,7 +83,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_BOOST_HEATING_OFF,
- {},
+ None,
"async_heating_boost_off",
)
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index e3e1464077a4fc..69a3e26ad790e1 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -60,8 +60,11 @@
"integration_not_found": {
"title": "Integration {domain} not found",
"fix_flow": {
+ "abort": {
+ "issue_ignored": "Not existing integration {domain} ignored."
+ },
"step": {
- "remove_entries": {
+ "init": {
"title": "[%key:component::homeassistant::issues::integration_not_found::title%]",
"description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
"menu_options": {
diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py
index 98363de1f8d27c..985e4819b24739 100644
--- a/homeassistant/components/homeassistant/triggers/event.py
+++ b/homeassistant/components/homeassistant/triggers/event.py
@@ -60,7 +60,6 @@ async def async_attach_trigger(
trigger_data = trigger_info["trigger_data"]
variables = trigger_info["variables"]
- template.attach(hass, config[CONF_EVENT_TYPE])
event_types = template.render_complex(
config[CONF_EVENT_TYPE], variables, limited=True
)
@@ -72,7 +71,6 @@ async def async_attach_trigger(
event_data_items: ItemsView | None = None
if CONF_EVENT_DATA in config:
# Render the schema input
- template.attach(hass, config[CONF_EVENT_DATA])
event_data = {}
event_data.update(
template.render_complex(config[CONF_EVENT_DATA], variables, limited=True)
@@ -94,7 +92,6 @@ async def async_attach_trigger(
event_context_items: ItemsView | None = None
if CONF_EVENT_CONTEXT in config:
# Render the schema input
- template.attach(hass, config[CONF_EVENT_CONTEXT])
event_context = {}
event_context.update(
template.render_complex(config[CONF_EVENT_CONTEXT], variables, limited=True)
diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py
index bc2c95675ad770..dac250792ea31a 100644
--- a/homeassistant/components/homeassistant/triggers/numeric_state.py
+++ b/homeassistant/components/homeassistant/triggers/numeric_state.py
@@ -108,7 +108,6 @@ async def async_attach_trigger(
below = config.get(CONF_BELOW)
above = config.get(CONF_ABOVE)
time_delta = config.get(CONF_FOR)
- template.attach(hass, time_delta)
value_template = config.get(CONF_VALUE_TEMPLATE)
unsub_track_same: dict[str, Callable[[], None]] = {}
armed_entities: set[str] = set()
@@ -119,9 +118,6 @@ async def async_attach_trigger(
trigger_data = trigger_info["trigger_data"]
_variables = trigger_info["variables"] or {}
- if value_template is not None:
- value_template.hass = hass
-
def variables(entity_id: str) -> dict[str, Any]:
"""Return a dict with trigger variables."""
trigger_info = {
diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py
index e0cbbf09610f15..53372cb479e211 100644
--- a/homeassistant/components/homeassistant/triggers/state.py
+++ b/homeassistant/components/homeassistant/triggers/state.py
@@ -117,7 +117,6 @@ async def async_attach_trigger(
match_to_state = process_state_match(MATCH_ALL)
time_delta = config.get(CONF_FOR)
- template.attach(hass, time_delta)
# If neither CONF_FROM or CONF_TO are specified,
# fire on all changes to the state or an attribute
match_all = all(
diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py
index 5dc520e8568349..91bab2d470a616 100644
--- a/homeassistant/components/homekit/type_thermostats.py
+++ b/homeassistant/components/homekit/type_thermostats.py
@@ -150,6 +150,8 @@
HVACAction.COOLING: HC_HEAT_COOL_COOL,
HVACAction.DRYING: HC_HEAT_COOL_COOL,
HVACAction.FAN: HC_HEAT_COOL_COOL,
+ HVACAction.PREHEATING: HC_HEAT_COOL_HEAT,
+ HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT,
}
FAN_STATE_INACTIVE = 0
@@ -624,8 +626,9 @@ def async_update_state(self, new_state: State) -> None:
# Set current operation mode for supported thermostats
if hvac_action := attributes.get(ATTR_HVAC_ACTION):
- homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action]
- self.char_current_heat_cool.set_value(homekit_hvac_action)
+ self.char_current_heat_cool.set_value(
+ HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF)
+ )
# Update current temperature
current_temp = _get_current_temperature(new_state, self._unit)
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
index 0d21ff9ba1d4a4..4da907daf3e7af 100644
--- a/homeassistant/components/homekit_controller/connection.py
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -845,21 +845,41 @@ async def async_request_update(self, now: datetime | None = None) -> None:
async def async_update(self, now: datetime | None = None) -> None:
"""Poll state of all entities attached to this bridge/accessory."""
+ to_poll = self.pollable_characteristics
+ accessories = self.entity_map.accessories
+
if (
- len(self.entity_map.accessories) == 1
+ len(accessories) == 1
and self.available
- and not (self.pollable_characteristics - self.watchable_characteristics)
+ and not (to_poll - self.watchable_characteristics)
and self.pairing.is_available
and await self.pairing.controller.async_reachable(
self.unique_id, timeout=5.0
)
):
# If its a single accessory and all chars are watchable,
- # we don't need to poll.
- _LOGGER.debug("Accessory is reachable, skip polling: %s", self.unique_id)
- return
+ # only poll the firmware version to keep the connection alive
+ # https://github.com/home-assistant/core/issues/123412
+ #
+ # Firmware revision is used here since iOS does this to keep camera
+ # connections alive, and the goal is to not regress
+ # https://github.com/home-assistant/core/issues/116143
+ # by polling characteristics that are not normally polled frequently
+ # and may not be tested by the device vendor.
+ #
+ _LOGGER.debug(
+ "Accessory is reachable, limiting poll to firmware version: %s",
+ self.unique_id,
+ )
+ first_accessory = accessories[0]
+ accessory_info = first_accessory.services.first(
+ service_type=ServicesTypes.ACCESSORY_INFORMATION
+ )
+ assert accessory_info is not None
+ firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
+ to_poll = {(first_accessory.aid, firmware_iid)}
- if not self.pollable_characteristics:
+ if not to_poll:
self.async_update_available_state()
_LOGGER.debug(
"HomeKit connection not polling any characteristics: %s", self.unique_id
@@ -892,9 +912,7 @@ async def async_update(self, now: datetime | None = None) -> None:
_LOGGER.debug("Starting HomeKit device update: %s", self.unique_id)
try:
- new_values_dict = await self.get_characteristics(
- self.pollable_characteristics
- )
+ new_values_dict = await self.get_characteristics(to_poll)
except AccessoryNotFoundError:
# Not only did the connection fail, but also the accessory is not
# visible on the network.
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index db01147494f44e..93ebbba62b1d83 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -144,7 +144,8 @@ async def async_set_direction(self, direction: str) -> None:
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan."""
if percentage == 0:
- return await self.async_turn_off()
+ await self.async_turn_off()
+ return
await self.async_put_characteristics(
{
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index 476d17d3515d95..007153aceafad6 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==3.2.1"],
+ "requirements": ["aiohomekit==3.2.2"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py
index 80345866b1ff77..f0fc2a40278519 100644
--- a/homeassistant/components/homematic/__init__.py
+++ b/homeassistant/components/homematic/__init__.py
@@ -573,6 +573,8 @@ def _create_ha_id(name, channel, param, count):
if count > 1 and param is not None:
return f"{name} {channel} {param}"
+ raise ValueError(f"Unable to create unique id for count:{count} and param:{param}")
+
def _hm_event_handler(hass, interface, device, caller, attribute, value):
"""Handle all pyhomematic device events."""
@@ -621,3 +623,4 @@ def _device_from_servicecall(hass, service):
for devices in hass.data[DATA_HOMEMATIC].devices.values():
if address in devices:
return devices[address]
+ return None
diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py
index 16c345c563548e..2be28487cbb9e0 100644
--- a/homeassistant/components/homematic/climate.py
+++ b/homeassistant/components/homematic/climate.py
@@ -125,6 +125,7 @@ def current_humidity(self):
for node in HM_HUMI_MAP:
if node in self._data:
return self._data[node]
+ return None
@property
def current_temperature(self):
@@ -132,6 +133,7 @@ def current_temperature(self):
for node in HM_TEMP_MAP:
if node in self._data:
return self._data[node]
+ return None
@property
def target_temperature(self):
@@ -141,7 +143,7 @@ def target_temperature(self):
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
- return None
+ return
self._hmdevice.writeNodeData(self._state, float(temperature))
diff --git a/homeassistant/components/homematicip_cloud/icons.json b/homeassistant/components/homematicip_cloud/icons.json
index 2e9f6158c357bc..73c60ea8cddc8b 100644
--- a/homeassistant/components/homematicip_cloud/icons.json
+++ b/homeassistant/components/homematicip_cloud/icons.json
@@ -7,6 +7,7 @@
"deactivate_vacation": "mdi:compass-off",
"set_active_climate_profile": "mdi:home-thermometer",
"dump_hap_config": "mdi:database-export",
- "reset_energy_counter": "mdi:reload"
+ "reset_energy_counter": "mdi:reload",
+ "set_home_cooling_mode": "mdi:snowflake"
}
}
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index 024cb2d9f2186d..b3e7eb9a72adba 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"quality_scale": "silver",
- "requirements": ["homematicip==1.1.1"]
+ "requirements": ["homematicip==1.1.2"]
}
diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py
index 37cda9e7683457..4c04e4a858b704 100644
--- a/homeassistant/components/homematicip_cloud/services.py
+++ b/homeassistant/components/homematicip_cloud/services.py
@@ -13,6 +13,7 @@
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import comp_entity_ids
from homeassistant.helpers.service import (
@@ -31,6 +32,7 @@
ATTR_CONFIG_OUTPUT_PATH = "config_output_path"
ATTR_DURATION = "duration"
ATTR_ENDTIME = "endtime"
+ATTR_COOLING = "cooling"
DEFAULT_CONFIG_FILE_PREFIX = "hmip-config"
@@ -42,6 +44,7 @@
SERVICE_DUMP_HAP_CONFIG = "dump_hap_config"
SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter"
SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile"
+SERVICE_SET_HOME_COOLING_MODE = "set_home_cooling_mode"
HMIPC_SERVICES = [
SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION,
@@ -52,6 +55,7 @@
SERVICE_DUMP_HAP_CONFIG,
SERVICE_RESET_ENERGY_COUNTER,
SERVICE_SET_ACTIVE_CLIMATE_PROFILE,
+ SERVICE_SET_HOME_COOLING_MODE,
]
SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION = vol.Schema(
@@ -107,6 +111,13 @@
{vol.Required(ATTR_ENTITY_ID): comp_entity_ids}
)
+SCHEMA_SET_HOME_COOLING_MODE = vol.Schema(
+ {
+ vol.Optional(ATTR_COOLING, default=True): cv.boolean,
+ vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)),
+ }
+)
+
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the HomematicIP Cloud services."""
@@ -135,6 +146,8 @@ async def async_call_hmipc_service(service: ServiceCall) -> None:
await _async_reset_energy_counter(hass, service)
elif service_name == SERVICE_SET_ACTIVE_CLIMATE_PROFILE:
await _set_active_climate_profile(hass, service)
+ elif service_name == SERVICE_SET_HOME_COOLING_MODE:
+ await _async_set_home_cooling_mode(hass, service)
hass.services.async_register(
domain=HMIPC_DOMAIN,
@@ -194,6 +207,14 @@ async def async_call_hmipc_service(service: ServiceCall) -> None:
schema=SCHEMA_RESET_ENERGY_COUNTER,
)
+ async_register_admin_service(
+ hass=hass,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_SET_HOME_COOLING_MODE,
+ service_func=async_call_hmipc_service,
+ schema=SCHEMA_SET_HOME_COOLING_MODE,
+ )
+
async def async_unload_services(hass: HomeAssistant):
"""Unload HomematicIP Cloud services."""
@@ -324,10 +345,25 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall)
await device.reset_energy_counter()
+async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall):
+ """Service to set the cooling mode."""
+ cooling = service.data[ATTR_COOLING]
+
+ if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
+ if home := _get_home(hass, hapid):
+ await home.set_cooling(cooling)
+ else:
+ for hap in hass.data[HMIPC_DOMAIN].values():
+ await hap.home.set_cooling(cooling)
+
+
def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None:
"""Return a HmIP home."""
if hap := hass.data[HMIPC_DOMAIN].get(hapid):
return hap.home
- _LOGGER.info("No matching access point found for access point id %s", hapid)
- return None
+ raise ServiceValidationError(
+ translation_domain=HMIPC_DOMAIN,
+ translation_key="access_point_not_found",
+ translation_placeholders={"id": hapid},
+ )
diff --git a/homeassistant/components/homematicip_cloud/services.yaml b/homeassistant/components/homematicip_cloud/services.yaml
index 9e8313397877f2..aced5c838a6a59 100644
--- a/homeassistant/components/homematicip_cloud/services.yaml
+++ b/homeassistant/components/homematicip_cloud/services.yaml
@@ -98,3 +98,14 @@ reset_energy_counter:
example: switch.livingroom
selector:
text:
+
+set_home_cooling_mode:
+ fields:
+ cooling:
+ default: true
+ selector:
+ boolean:
+ accesspoint_id:
+ example: 3014xxxxxxxxxxxxxxxxxxxx
+ selector:
+ text:
diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json
index 3795508d75dcff..a7c795c81f6d37 100644
--- a/homeassistant/components/homematicip_cloud/strings.json
+++ b/homeassistant/components/homematicip_cloud/strings.json
@@ -26,6 +26,11 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
+ "exceptions": {
+ "access_point_not_found": {
+ "message": "No matching access point found for access point id {id}"
+ }
+ },
"services": {
"activate_eco_mode_with_duration": {
"name": "Activate eco mode with duration",
@@ -134,6 +139,20 @@
"description": "The ID of the measuring entity. Use 'all' keyword to reset all energy counters."
}
}
+ },
+ "set_home_cooling_mode": {
+ "name": "Set home cooling mode",
+ "description": "Set the heating/cooling mode for the entire home",
+ "fields": {
+ "accesspoint_id": {
+ "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]",
+ "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::description%]"
+ },
+ "cooling": {
+ "name": "Cooling",
+ "description": "Enable for cooling mode, disable for heating mode"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index 474d63e943d09c..dbad91b1fb8661 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -7,6 +7,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==v6.1.1"],
+ "requirements": ["python-homewizard-energy==v6.2.0"],
"zeroconf": ["_hwenergy._tcp.local."]
}
diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py
index cf39bc72ec61bf..448487cb8b0051 100644
--- a/homeassistant/components/homeworks/__init__.py
+++ b/homeassistant/components/homeworks/__init__.py
@@ -9,7 +9,12 @@
from typing import Any
from pyhomeworks import exceptions as hw_exceptions
-from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks
+from pyhomeworks.pyhomeworks import (
+ HW_BUTTON_PRESSED,
+ HW_BUTTON_RELEASED,
+ HW_LOGIN_INCORRECT,
+ Homeworks,
+)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -17,7 +22,9 @@
CONF_HOST,
CONF_ID,
CONF_NAME,
+ CONF_PASSWORD,
CONF_PORT,
+ CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
@@ -137,12 +144,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def hw_callback(msg_type: Any, values: Any) -> None:
"""Dispatch state changes."""
_LOGGER.debug("callback: %s, %s", msg_type, values)
+ if msg_type == HW_LOGIN_INCORRECT:
+ _LOGGER.debug("login incorrect")
+ return
addr = values[0]
signal = f"homeworks_entity_{controller_id}_{addr}"
dispatcher_send(hass, signal, msg_type, values)
config = entry.options
- controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback)
+ controller = Homeworks(
+ config[CONF_HOST],
+ config[CONF_PORT],
+ hw_callback,
+ entry.data.get(CONF_USERNAME),
+ entry.data.get(CONF_PASSWORD),
+ )
try:
await hass.async_add_executor_job(controller.connect)
except hw_exceptions.HomeworksException as err:
diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py
index ec381c3331f98b..9247670b40b35a 100644
--- a/homeassistant/components/homeworks/config_flow.py
+++ b/homeassistant/components/homeworks/config_flow.py
@@ -14,7 +14,13 @@
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_USERNAME,
+)
from homeassistant.core import async_get_hass, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import (
@@ -62,6 +68,10 @@
mode=selector.NumberSelectorMode.BOX,
)
),
+ vol.Optional(CONF_USERNAME): selector.TextSelector(),
+ vol.Optional(CONF_PASSWORD): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
}
LIGHT_EDIT: VolDictType = {
@@ -89,13 +99,20 @@
}
-validate_addr = cv.matches_regex(r"\[(?:\d\d:)?\d\d:\d\d:\d\d\]")
+validate_addr = cv.matches_regex(r"\[(?:\d\d:){2,4}\d\d\]")
+
+
+def _validate_credentials(user_input: dict[str, Any]) -> None:
+ """Validate credentials."""
+ if CONF_PASSWORD in user_input and CONF_USERNAME not in user_input:
+ raise SchemaFlowError("need_username_with_password")
async def validate_add_controller(
handler: ConfigFlow | SchemaOptionsFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate controller setup."""
+ _validate_credentials(user_input)
user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME])
user_input[CONF_PORT] = int(user_input[CONF_PORT])
try:
@@ -128,7 +145,13 @@ def _try_connect(host: str, port: int) -> None:
_LOGGER.debug(
"Trying to connect to %s:%s", user_input[CONF_HOST], user_input[CONF_PORT]
)
- controller = Homeworks(host, port, lambda msg_types, values: None)
+ controller = Homeworks(
+ host,
+ port,
+ lambda msg_types, values: None,
+ user_input.get(CONF_USERNAME),
+ user_input.get(CONF_PASSWORD),
+ )
controller.connect()
controller.close()
@@ -138,7 +161,14 @@ def _try_connect(host: str, port: int) -> None:
_try_connect, user_input[CONF_HOST], user_input[CONF_PORT]
)
except hw_exceptions.HomeworksConnectionFailed as err:
+ _LOGGER.debug("Caught HomeworksConnectionFailed")
raise SchemaFlowError("connection_error") from err
+ except hw_exceptions.HomeworksInvalidCredentialsProvided as err:
+ _LOGGER.debug("Caught HomeworksInvalidCredentialsProvided")
+ raise SchemaFlowError("invalid_credentials") from err
+ except hw_exceptions.HomeworksNoCredentialsProvided as err:
+ _LOGGER.debug("Caught HomeworksNoCredentialsProvided")
+ raise SchemaFlowError("credentials_needed") from err
except Exception as err:
_LOGGER.exception("Caught unexpected exception %s")
raise SchemaFlowError("unknown_error") from err
@@ -529,6 +559,7 @@ async def _validate_edit_controller(
self, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate controller setup."""
+ _validate_credentials(user_input)
user_input[CONF_PORT] = int(user_input[CONF_PORT])
our_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
@@ -569,12 +600,19 @@ async def async_step_reconfigure(
except SchemaFlowError as err:
errors["base"] = str(err)
else:
+ password = user_input.pop(CONF_PASSWORD, None)
+ username = user_input.pop(CONF_USERNAME, None)
+ new_data = entry.data | {
+ CONF_PASSWORD: password,
+ CONF_USERNAME: username,
+ }
new_options = entry.options | {
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
}
return self.async_update_reload_and_abort(
entry,
+ data=new_data,
options=new_options,
reason="reconfigure_successful",
reload_even_if_entry_is_unchanged=False,
@@ -603,8 +641,14 @@ async def async_step_user(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
name = user_input.pop(CONF_NAME)
+ password = user_input.pop(CONF_PASSWORD, None)
+ username = user_input.pop(CONF_USERNAME, None)
user_input |= {CONF_DIMMERS: [], CONF_KEYPADS: []}
- return self.async_create_entry(title=name, data={}, options=user_input)
+ return self.async_create_entry(
+ title=name,
+ data={CONF_PASSWORD: password, CONF_USERNAME: username},
+ options=user_input,
+ )
return self.async_show_form(
step_id="user",
diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json
index 1ba0672c9f1042..a399e0a98e72ad 100644
--- a/homeassistant/components/homeworks/manifest.json
+++ b/homeassistant/components/homeworks/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homeworks",
"iot_class": "local_push",
"loggers": ["pyhomeworks"],
- "requirements": ["pyhomeworks==1.1.0"]
+ "requirements": ["pyhomeworks==1.1.1"]
}
diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json
index b0d0f6e61e1e5b..a9dcab2f1e0a2e 100644
--- a/homeassistant/components/homeworks/strings.json
+++ b/homeassistant/components/homeworks/strings.json
@@ -2,8 +2,11 @@
"config": {
"error": {
"connection_error": "Could not connect to the controller.",
+ "credentials_needed": "The controller needs credentials.",
"duplicated_controller_id": "The controller name is already in use.",
"duplicated_host_port": "The specified host and port is already configured.",
+ "invalid_credentials": "The provided credentials are not valid.",
+ "need_username_with_password": "Credentials must be either a username and a password or only a username.",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@@ -22,7 +25,13 @@
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
- "port": "[%key:common::config_flow::data::port%]"
+ "port": "[%key:common::config_flow::data::port%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Optional password, leave blank if your system does not need credentials or only needs a single credential",
+ "username": "Optional username, leave blank if your system does not need login credentials"
},
"description": "Modify a Lutron Homeworks controller connection settings"
},
@@ -30,10 +39,14 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "Controller name",
- "port": "[%key:common::config_flow::data::port%]"
+ "port": "[%key:common::config_flow::data::port%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "name": "A unique name identifying the Lutron Homeworks controller"
+ "name": "A unique name identifying the Lutron Homeworks controller",
+ "password": "[%key:component::homeworks::config::step::reconfigure::data_description::password%]",
+ "username": "[%key:component::homeworks::config::step::reconfigure::data_description::username%]"
},
"description": "Add a Lutron Homeworks controller"
}
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index d9260fc3be5333..141cb87f1174f1 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -42,6 +42,7 @@
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.unit_conversion import TemperatureConverter
from . import HoneywellData
from .const import (
@@ -259,7 +260,9 @@ def min_temp(self) -> float:
self._device.raw_ui_data["HeatLowerSetptLimit"],
]
)
- return DEFAULT_MIN_TEMP
+ return TemperatureConverter.convert(
+ DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit
+ )
@property
def max_temp(self) -> float:
@@ -275,7 +278,9 @@ def max_temp(self) -> float:
self._device.raw_ui_data["HeatUpperSetptLimit"],
]
)
- return DEFAULT_MAX_TEMP
+ return TemperatureConverter.convert(
+ DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit
+ )
@property
def current_humidity(self) -> int | None:
diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py
index 85908a45af41d2..0eeb443cf2dfee 100644
--- a/homeassistant/components/hp_ilo/sensor.py
+++ b/homeassistant/components/hp_ilo/sensor.py
@@ -131,9 +131,6 @@ def __init__(
self._unit_of_measurement = unit_of_measurement
self._ilo_function = SENSOR_TYPES[sensor_type][1]
self.hp_ilo_data = hp_ilo_data
-
- if sensor_value_template is not None:
- sensor_value_template.hass = hass
self._sensor_value_template = sensor_value_template
self._state = None
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index 798589d2807a6b..8082ca37aa3820 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -533,7 +533,7 @@ def _push_message(self, payload, **kwargs):
elif response.status_code > 399:
_LOGGER.error(
"There was an issue sending the notification %s: %s",
- response.status,
+ response.status_code,
response.text,
)
diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py
index 3c845601183b92..ebc0594e15afeb 100644
--- a/homeassistant/components/http/headers.py
+++ b/homeassistant/components/http/headers.py
@@ -31,13 +31,10 @@ async def headers_middleware(
try:
response = await handler(request)
except HTTPException as err:
- for key, value in added_headers.items():
- err.headers[key] = value
+ err.headers.update(added_headers)
raise
- for key, value in added_headers.items():
- response.headers[key] = value
-
+ response.headers.update(added_headers)
return response
app.middlewares.append(headers_middleware)
diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py
index a7280fb9b2f63b..29c5840a4bf291 100644
--- a/homeassistant/components/http/static.py
+++ b/homeassistant/components/http/static.py
@@ -3,81 +3,46 @@
from __future__ import annotations
from collections.abc import Mapping
-import mimetypes
from pathlib import Path
from typing import Final
-from aiohttp import hdrs
+from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
from aiohttp.web import FileResponse, Request, StreamResponse
-from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound
+from aiohttp.web_fileresponse import CONTENT_TYPES, FALLBACK_CONTENT_TYPE
from aiohttp.web_urldispatcher import StaticResource
from lru import LRU
-from .const import KEY_HASS
-
CACHE_TIME: Final = 31 * 86400 # = 1 month
CACHE_HEADER = f"public, max-age={CACHE_TIME}"
-CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER}
-PATH_CACHE: LRU[tuple[str, Path], tuple[Path | None, str | None]] = LRU(512)
-
-
-def _get_file_path(rel_url: str, directory: Path) -> Path | None:
- """Return the path to file on disk or None."""
- filename = Path(rel_url)
- if filename.anchor:
- # rel_url is an absolute name like
- # /static/\\machine_name\c$ or /static/D:\path
- # where the static dir is totally different
- raise HTTPForbidden
- filepath: Path = directory.joinpath(filename).resolve()
- filepath.relative_to(directory)
- # on opening a dir, load its contents if allowed
- if filepath.is_dir():
- return None
- if filepath.is_file():
- return filepath
- raise FileNotFoundError
+CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER}
+RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512)
class CachingStaticResource(StaticResource):
"""Static Resource handler that will add cache headers."""
async def _handle(self, request: Request) -> StreamResponse:
- """Return requested file from disk as a FileResponse."""
+ """Wrap base handler to cache file path resolution and content type guess."""
rel_url = request.match_info["filename"]
key = (rel_url, self._directory)
- if (filepath_content_type := PATH_CACHE.get(key)) is None:
- hass = request.app[KEY_HASS]
- try:
- filepath = await hass.async_add_executor_job(_get_file_path, *key)
- except (ValueError, FileNotFoundError) as error:
- # relatively safe
- raise HTTPNotFound from error
- except HTTPForbidden:
- # forbidden
- raise
- except Exception as error:
- # perm error or other kind!
- request.app.logger.exception("Unexpected exception")
- raise HTTPNotFound from error
+ response: StreamResponse
- content_type: str | None = None
- if filepath is not None:
- content_type = (mimetypes.guess_type(rel_url))[
- 0
- ] or "application/octet-stream"
- PATH_CACHE[key] = (filepath, content_type)
+ if key in RESPONSE_CACHE:
+ file_path, content_type = RESPONSE_CACHE[key]
+ response = FileResponse(file_path, chunk_size=self._chunk_size)
+ response.headers[CONTENT_TYPE] = content_type
else:
- filepath, content_type = filepath_content_type
-
- if filepath and content_type:
- return FileResponse(
- filepath,
- chunk_size=self._chunk_size,
- headers={
- hdrs.CACHE_CONTROL: CACHE_HEADER,
- hdrs.CONTENT_TYPE: content_type,
- },
+ response = await super()._handle(request)
+ if not isinstance(response, FileResponse):
+ # Must be directory index; ignore caching
+ return response
+ file_path = response._path # noqa: SLF001
+ response.content_type = (
+ CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE
)
+ # Cache actual header after setter construction.
+ content_type = response.headers[CONTENT_TYPE]
+ RESPONSE_CACHE[key] = (file_path, content_type)
- raise HTTPForbidden if filepath is None else HTTPNotFound
+ response.headers[CACHE_CONTROL] = CACHE_HEADER
+ return response
diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py
index ce94eaaf5a0375..37e2bd3e3ba882 100644
--- a/homeassistant/components/humidifier/__init__.py
+++ b/homeassistant/components/humidifier/__init__.py
@@ -92,9 +92,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
await component.async_setup(config)
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
- component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
- component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
+ component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
component.async_register_entity_service(
SERVICE_SET_MODE,
{vol.Required(ATTR_MODE): cv.string},
diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py
index de1d4c871e3e1e..06440480277856 100644
--- a/homeassistant/components/humidifier/device_action.py
+++ b/homeassistant/components/humidifier/device_action.py
@@ -99,9 +99,10 @@ async def async_call_action_from_config(
service = const.SERVICE_SET_MODE
service_data[ATTR_MODE] = config[ATTR_MODE]
else:
- return await toggle_entity.async_call_action_from_config(
+ await toggle_entity.async_call_action_from_config(
hass, config, variables, context, DOMAIN
)
+ return
await hass.services.async_call(
DOMAIN, service, service_data, blocking=True, context=context
diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py
index 6f63641b722032..f8c7ac43b94d1f 100644
--- a/homeassistant/components/hunterdouglas_powerview/__init__.py
+++ b/homeassistant/components/hunterdouglas_powerview/__init__.py
@@ -13,7 +13,6 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, HUB_EXCEPTIONS
from .coordinator import PowerviewShadeUpdateCoordinator
@@ -22,7 +21,6 @@
PARALLEL_UPDATES = 1
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [
Platform.BUTTON,
diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py
index a97471083937d0..810dd4df92d46f 100644
--- a/homeassistant/components/husqvarna_automower/button.py
+++ b/homeassistant/components/husqvarna_automower/button.py
@@ -25,7 +25,9 @@ async def async_setup_entry(
"""Set up button platform."""
coordinator = entry.runtime_data
async_add_entities(
- AutomowerButtonEntity(mower_id, coordinator) for mower_id in coordinator.data
+ AutomowerButtonEntity(mower_id, coordinator)
+ for mower_id in coordinator.data
+ if coordinator.data[mower_id].capabilities.can_confirm_error
)
@@ -33,7 +35,6 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
"""Defining the AutomowerButtonEntity."""
_attr_translation_key = "confirm_error"
- _attr_entity_registry_enabled_default = False
def __init__(
self,
diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json
index a9002c5b44a254..9dc1cbeb667645 100644
--- a/homeassistant/components/husqvarna_automower/icons.json
+++ b/homeassistant/components/husqvarna_automower/icons.json
@@ -34,6 +34,7 @@
}
},
"services": {
- "override_schedule": "mdi:debug-step-over"
+ "override_schedule": "mdi:debug-step-over",
+ "override_schedule_work_area": "mdi:land-fields"
}
}
diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py
index dd2129599fb44f..ac0f1fd6af25ad 100644
--- a/homeassistant/components/husqvarna_automower/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower/lawn_mower.py
@@ -2,8 +2,9 @@
from datetime import timedelta
import logging
+from typing import TYPE_CHECKING
-from aioautomower.model import MowerActivities, MowerStates
+from aioautomower.model import MowerActivities, MowerStates, WorkArea
import voluptuous as vol
from homeassistant.components.lawn_mower import (
@@ -12,10 +13,12 @@
LawnMowerEntityFeature,
)
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AutomowerConfigEntry
+from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception
@@ -67,6 +70,18 @@ async def async_setup_entry(
},
"async_override_schedule",
)
+ platform.async_register_entity_service(
+ "override_schedule_work_area",
+ {
+ vol.Required("work_area_id"): vol.Coerce(int),
+ vol.Required("duration"): vol.All(
+ cv.time_period,
+ cv.positive_timedelta,
+ vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
+ ),
+ },
+ "async_override_schedule_work_area",
+ )
class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
@@ -98,6 +113,11 @@ def activity(self) -> LawnMowerActivity:
return LawnMowerActivity.DOCKED
return LawnMowerActivity.ERROR
+ @property
+ def work_areas(self) -> dict[int, WorkArea] | None:
+ """Return the work areas of the mower."""
+ return self.mower_attributes.work_areas
+
@handle_sending_exception()
async def async_start_mowing(self) -> None:
"""Resume schedule."""
@@ -122,3 +142,22 @@ async def async_override_schedule(
await self.coordinator.api.commands.start_for(self.mower_id, duration)
if override_mode == PARK:
await self.coordinator.api.commands.park_for(self.mower_id, duration)
+
+ @handle_sending_exception()
+ async def async_override_schedule_work_area(
+ self, work_area_id: int, duration: timedelta
+ ) -> None:
+ """Override the schedule with a certain work area."""
+ if not self.mower_attributes.capabilities.work_areas:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="work_areas_not_supported"
+ )
+ if TYPE_CHECKING:
+ assert self.work_areas is not None
+ if work_area_id not in self.work_areas:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN, translation_key="work_area_not_existing"
+ )
+ await self.coordinator.api.commands.start_in_workarea(
+ self.mower_id, work_area_id, duration
+ )
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index f27b04ef0c0b52..7326408e403fba 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
- "requirements": ["aioautomower==2024.6.4"]
+ "requirements": ["aioautomower==2024.8.0"]
}
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index 2c8d369ea3aaf3..0e3e6771cec250 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -1,12 +1,14 @@
"""Creates the sensor entities for the mower."""
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
+from zoneinfo import ZoneInfo
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons
+from aioautomower.utils import naive_to_aware
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -18,6 +20,7 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -25,6 +28,8 @@
_LOGGER = logging.getLogger(__name__)
+ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
+
ERROR_KEY_LIST = [
"no_error",
"alarm_mower_in_motion",
@@ -211,11 +216,23 @@ def _get_current_work_area_name(data: MowerAttributes) -> str:
return data.work_areas[data.mower.work_area_id].name
+@callback
+def _get_current_work_area_dict(data: MowerAttributes) -> Mapping[str, Any]:
+ """Return the name of the current work area."""
+ if TYPE_CHECKING:
+ # Sensor does not get created if it is None
+ assert data.work_areas is not None
+ return {ATTR_WORK_AREA_ID_ASSIGNMENT: data.work_area_dict}
+
+
@dataclass(frozen=True, kw_only=True)
class AutomowerSensorEntityDescription(SensorEntityDescription):
"""Describes Automower sensor entity."""
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
+ extra_state_attributes_fn: Callable[[MowerAttributes], Mapping[str, Any] | None] = (
+ lambda _: None
+ )
option_fn: Callable[[MowerAttributes], list[str] | None] = lambda _: None
value_fn: Callable[[MowerAttributes], StateType | datetime]
@@ -324,7 +341,10 @@ class AutomowerSensorEntityDescription(SensorEntityDescription):
key="next_start_timestamp",
translation_key="next_start_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
- value_fn=lambda data: data.planner.next_start_datetime,
+ value_fn=lambda data: naive_to_aware(
+ data.planner.next_start_datetime_naive,
+ ZoneInfo(str(dt_util.DEFAULT_TIME_ZONE)),
+ ),
),
AutomowerSensorEntityDescription(
key="error",
@@ -347,6 +367,7 @@ class AutomowerSensorEntityDescription(SensorEntityDescription):
translation_key="work_area",
device_class=SensorDeviceClass.ENUM,
exists_fn=lambda data: data.capabilities.work_areas,
+ extra_state_attributes_fn=_get_current_work_area_dict,
option_fn=_get_work_area_names,
value_fn=_get_current_work_area_name,
),
@@ -372,6 +393,7 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity):
"""Defining the Automower Sensors with AutomowerSensorEntityDescription."""
entity_description: AutomowerSensorEntityDescription
+ _unrecorded_attributes = frozenset({ATTR_WORK_AREA_ID_ASSIGNMENT})
def __init__(
self,
@@ -393,3 +415,8 @@ def native_value(self) -> StateType | datetime:
def options(self) -> list[str] | None:
"""Return the option of the sensor."""
return self.entity_description.option_fn(self.mower_attributes)
+
+ @property
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ """Return the state attributes."""
+ return self.entity_description.extra_state_attributes_fn(self.mower_attributes)
diff --git a/homeassistant/components/husqvarna_automower/services.yaml b/homeassistant/components/husqvarna_automower/services.yaml
index 94687a2ebfadb0..29c89360d1ef84 100644
--- a/homeassistant/components/husqvarna_automower/services.yaml
+++ b/homeassistant/components/husqvarna_automower/services.yaml
@@ -19,3 +19,22 @@ override_schedule:
options:
- "mow"
- "park"
+
+override_schedule_work_area:
+ target:
+ entity:
+ integration: "husqvarna_automower"
+ domain: "lawn_mower"
+ fields:
+ duration:
+ required: true
+ example: "{'days': 1, 'hours': 12, 'minutes': 30}"
+ selector:
+ duration:
+ enable_day: true
+ work_area_id:
+ required: true
+ example: "123"
+ selector:
+ text:
+ type: number
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index be17cc25e32bb9..c34a5dd334066c 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -254,6 +254,11 @@
"state": {
"my_lawn": "My lawn",
"no_work_area_active": "No work area active"
+ },
+ "state_attributes": {
+ "work_area_id_assignment": {
+ "name": "Work area ID assignment"
+ }
}
}
},
@@ -269,6 +274,12 @@
"exceptions": {
"command_send_failed": {
"message": "Failed to send command: {exception}"
+ },
+ "work_areas_not_supported": {
+ "message": "This mower does not support work areas."
+ },
+ "work_area_not_existing": {
+ "message": "The selected work area does not exist."
}
},
"selector": {
@@ -293,6 +304,20 @@
"description": "With which action the schedule should be overridden."
}
}
+ },
+ "override_schedule_work_area": {
+ "name": "Override schedule work area",
+ "description": "Override the schedule of the mower for a duration of time in the selected work area.",
+ "fields": {
+ "duration": {
+ "name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]",
+ "description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]"
+ },
+ "work_area_id": {
+ "name": "Work area ID",
+ "description": "In which work area the mower should mow."
+ }
+ }
}
}
}
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index 0e00d237faec6e..9b6dcadf95f97f 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -110,7 +110,7 @@ async def async_setup_entry(
)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(SERVICE_RESUME, {}, "resume")
+ platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
platform.async_register_entity_service(
SERVICE_START_WATERING, SCHEMA_START_WATERING, "start_watering"
)
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index c6f4d7d8dcd700..9b733cb73d0cb6 100644
--- a/homeassistant/components/hydrawise/manifest.json
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
- "requirements": ["pydrawise==2024.6.4"]
+ "requirements": ["pydrawise==2024.8.0"]
}
diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py
index 431a1abd2e1442..5bdfd00dc60b71 100644
--- a/homeassistant/components/icloud/__init__.py
+++ b/homeassistant/components/icloud/__init__.py
@@ -69,8 +69,6 @@
}
)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an iCloud account from a config entry."""
diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py
index 988073384f8cd5..9536cd9ee5c983 100644
--- a/homeassistant/components/icloud/account.py
+++ b/homeassistant/components/icloud/account.py
@@ -117,7 +117,7 @@ def setup(self) -> None:
if self.api.requires_2fa:
# Trigger a new log in to ensure the user enters the 2FA code again.
- raise PyiCloudFailedLoginException
+ raise PyiCloudFailedLoginException # noqa: TRY301
except PyiCloudFailedLoginException:
self.api = None
diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py
index 36fe880ec79628..30942ce6727b9a 100644
--- a/homeassistant/components/icloud/config_flow.py
+++ b/homeassistant/components/icloud/config_flow.py
@@ -141,7 +141,7 @@ async def _validate_and_create_entry(self, user_input, step_id):
getattr, self.api, "devices"
)
if not devices:
- raise PyiCloudNoDevicesException
+ raise PyiCloudNoDevicesException # noqa: TRY301
except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException):
_LOGGER.error("No device found in the iCloud account: %s", self._username)
self.api = None
@@ -264,13 +264,13 @@ async def async_step_verification_code(self, user_input=None, errors=None):
if not await self.hass.async_add_executor_job(
self.api.validate_2fa_code, self._verification_code
):
- raise PyiCloudException("The code you entered is not valid.")
+ raise PyiCloudException("The code you entered is not valid.") # noqa: TRY301
elif not await self.hass.async_add_executor_job(
self.api.validate_verification_code,
self._trusted_device,
self._verification_code,
):
- raise PyiCloudException("The code you entered is not valid.")
+ raise PyiCloudException("The code you entered is not valid.") # noqa: TRY301
except PyiCloudException as error:
# Reset to the initial 2FA state to allow the user to retry
_LOGGER.error("Failed to verify verification code: %s", error)
diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py
index f0d8013cb50c60..56a377ac2df738 100644
--- a/homeassistant/components/idasen_desk/__init__.py
+++ b/homeassistant/components/idasen_desk/__init__.py
@@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
if not await coordinator.async_connect():
- raise ConfigEntryNotReady(f"Unable to connect to desk {address}")
+ raise ConfigEntryNotReady(f"Unable to connect to desk {address}") # noqa: TRY301
except (AuthFailedError, TimeoutError, BleakError, Exception) as ex:
raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex
diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py
index 03b6acb204cc4d..cc601888f56884 100644
--- a/homeassistant/components/influxdb/sensor.py
+++ b/homeassistant/components/influxdb/sensor.py
@@ -194,39 +194,30 @@ def __init__(self, hass, influx, query):
"""Initialize the sensor."""
self._name = query.get(CONF_NAME)
self._unit_of_measurement = query.get(CONF_UNIT_OF_MEASUREMENT)
- value_template = query.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- self._value_template = value_template
- self._value_template.hass = hass
- else:
- self._value_template = None
+ self._value_template = query.get(CONF_VALUE_TEMPLATE)
self._state = None
self._hass = hass
self._attr_unique_id = query.get(CONF_UNIQUE_ID)
if query[CONF_LANGUAGE] == LANGUAGE_FLUX:
- query_clause = query.get(CONF_QUERY)
- query_clause.hass = hass
self.data = InfluxFluxSensorData(
influx,
query.get(CONF_BUCKET),
query.get(CONF_RANGE_START),
query.get(CONF_RANGE_STOP),
- query_clause,
+ query.get(CONF_QUERY),
query.get(CONF_IMPORTS),
query.get(CONF_GROUP_FUNCTION),
)
else:
- where_clause = query.get(CONF_WHERE)
- where_clause.hass = hass
self.data = InfluxQLSensorData(
influx,
query.get(CONF_DB_NAME),
query.get(CONF_GROUP_FUNCTION),
query.get(CONF_FIELD),
query.get(CONF_MEASUREMENT_NAME),
- where_clause,
+ query.get(CONF_WHERE),
)
@property
diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py
index 57165c5508a1ed..54457ab2fb763d 100644
--- a/homeassistant/components/input_boolean/__init__.py
+++ b/homeassistant/components/input_boolean/__init__.py
@@ -138,11 +138,11 @@ async def reload_service_handler(service_call: ServiceCall) -> None:
schema=RELOAD_SERVICE_SCHEMA,
)
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
- component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
+ component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
- component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
+ component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
return True
diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py
index 1488d80a1f56f3..6584b40fb55bb0 100644
--- a/homeassistant/components/input_button/__init__.py
+++ b/homeassistant/components/input_button/__init__.py
@@ -123,7 +123,7 @@ async def reload_service_handler(service_call: ServiceCall) -> None:
schema=RELOAD_SERVICE_SCHEMA,
)
- component.async_register_entity_service(SERVICE_PRESS, {}, "_async_press_action")
+ component.async_register_entity_service(SERVICE_PRESS, None, "_async_press_action")
return True
diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py
index f55ceabc6f0008..d52bfedfe77027 100644
--- a/homeassistant/components/input_number/__init__.py
+++ b/homeassistant/components/input_number/__init__.py
@@ -157,9 +157,9 @@ async def reload_service_handler(service_call: ServiceCall) -> None:
"async_set_value",
)
- component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment")
+ component.async_register_entity_service(SERVICE_INCREMENT, None, "async_increment")
- component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement")
+ component.async_register_entity_service(SERVICE_DECREMENT, None, "async_decrement")
return True
diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py
index 44d2df02a921e7..6efe16240cba4b 100644
--- a/homeassistant/components/input_select/__init__.py
+++ b/homeassistant/components/input_select/__init__.py
@@ -183,13 +183,13 @@ async def reload_service_handler(service_call: ServiceCall) -> None:
component.async_register_entity_service(
SERVICE_SELECT_FIRST,
- {},
+ None,
InputSelect.async_first.__name__,
)
component.async_register_entity_service(
SERVICE_SELECT_LAST,
- {},
+ None,
InputSelect.async_last.__name__,
)
diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml
index 92279e58a5404c..04a09e5366a872 100644
--- a/homeassistant/components/input_select/services.yaml
+++ b/homeassistant/components/input_select/services.yaml
@@ -48,6 +48,7 @@ set_options:
required: true
example: '["Item A", "Item B", "Item C"]'
selector:
- object:
+ text:
+ multiple: true
reload:
diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py
index 0ec2434bc823bf..ff72f90a87edb4 100644
--- a/homeassistant/components/insteon/__init__.py
+++ b/homeassistant/components/insteon/__init__.py
@@ -10,8 +10,7 @@
from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, device_registry as dr
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers import device_registry as dr
from . import api
from .const import (
@@ -36,8 +35,6 @@
_LOGGER = logging.getLogger(__name__)
OPTIONS = "options"
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_get_device_config(hass, config_entry):
"""Initiate the connection and services."""
@@ -77,11 +74,6 @@ async def close_insteon_connection(*args):
await async_close()
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Insteon platform."""
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an Insteon entry."""
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index b1716a8d2d2b18..001f2515ebfe04 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -16,6 +16,7 @@
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
+ CoverDeviceClass,
)
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.lock import (
@@ -23,11 +24,14 @@
SERVICE_LOCK,
SERVICE_UNLOCK,
)
+from homeassistant.components.media_player import MediaPlayerDeviceClass
+from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
+ ValveDeviceClass,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -67,6 +71,13 @@
"DOMAIN",
]
+ONOFF_DEVICE_CLASSES = {
+ CoverDeviceClass,
+ ValveDeviceClass,
+ SwitchDeviceClass,
+ MediaPlayerDeviceClass,
+}
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Intent component."""
@@ -85,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
HOMEASSISTANT_DOMAIN,
SERVICE_TURN_ON,
description="Turns on/opens a device or entity",
+ device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
@@ -94,6 +106,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
HOMEASSISTANT_DOMAIN,
SERVICE_TURN_OFF,
description="Turns off/closes a device or entity",
+ device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
@@ -103,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
HOMEASSISTANT_DOMAIN,
SERVICE_TOGGLE,
description="Toggles a device or entity",
+ device_classes=ONOFF_DEVICE_CLASSES,
),
)
intent.async_register(
@@ -358,6 +372,7 @@ def __init__(self) -> None:
},
description="Sets the position of a device or entity",
platforms={COVER_DOMAIN, VALVE_DOMAIN},
+ device_classes={CoverDeviceClass, ValveDeviceClass},
)
def get_domain_and_service(
diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py
index d6fbb1edd80b49..371163ce00aad2 100644
--- a/homeassistant/components/intent_script/__init__.py
+++ b/homeassistant/components/intent_script/__init__.py
@@ -90,7 +90,6 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> None:
"""Load YAML intents into the intent system."""
- template.attach(hass, intents)
hass.data[DOMAIN] = intents
for intent_type, conf in intents.items():
diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py
index 6609fb59400e5c..ee489e88349415 100644
--- a/homeassistant/components/iotty/switch.py
+++ b/homeassistant/components/iotty/switch.py
@@ -53,7 +53,7 @@ async def async_setup_entry(
def async_update_data() -> None:
"""Handle updated data from the API endpoint."""
if not coordinator.last_update_success:
- return None
+ return
devices = coordinator.data.devices
entities = []
diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py
index a96846558faacc..39bf39bcbe0327 100644
--- a/homeassistant/components/irish_rail_transport/sensor.py
+++ b/homeassistant/components/irish_rail_transport/sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import timedelta
+from typing import Any
from pyirishrail.pyirishrail import IrishRailRTPI
import voluptuous as vol
@@ -104,7 +105,7 @@ def native_value(self):
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self._times:
next_up = "None"
@@ -127,6 +128,7 @@ def extra_state_attributes(self):
ATTR_NEXT_UP: next_up,
ATTR_TRAIN_TYPE: self._times[0][ATTR_TRAIN_TYPE],
}
+ return None
@property
def native_unit_of_measurement(self):
diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py
index e8424478d865cd..aefb14b689ba1e 100644
--- a/homeassistant/components/iron_os/coordinator.py
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -46,4 +46,8 @@ async def _async_update_data(self) -> LiveDataResponse:
async def _async_setup(self) -> None:
"""Set up the coordinator."""
- self.device_info = await self.device.get_device_info()
+ try:
+ self.device_info = await self.device.get_device_info()
+
+ except CommunicationError as e:
+ raise UpdateFailed("Cannot connect to device") from e
diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py
index 089afc88564262..d61eba343ac117 100644
--- a/homeassistant/components/islamic_prayer_times/__init__.py
+++ b/homeassistant/components/islamic_prayer_times/__init__.py
@@ -7,14 +7,12 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import entity_registry as er
-from .const import DOMAIN
from .coordinator import IslamicPrayerDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json
index 359d4626bd404a..a90031c088d570 100644
--- a/homeassistant/components/islamic_prayer_times/strings.json
+++ b/homeassistant/components/islamic_prayer_times/strings.json
@@ -45,7 +45,7 @@
"jakim": "Jabatan Kemajuan Islam Malaysia (JAKIM)",
"tunisia": "Tunisia",
"algeria": "Algeria",
- "kemenag": "ementerian Agama Republik Indonesia",
+ "kemenag": "Kementerian Agama Republik Indonesia",
"morocco": "Morocco",
"portugal": "Comunidade Islamica de Lisboa",
"jordan": "Ministry of Awqaf, Islamic Affairs and Holy Places, Jordan",
diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py
index c32ca28779334a..0f241041c0d229 100644
--- a/homeassistant/components/itunes/media_player.py
+++ b/homeassistant/components/itunes/media_player.py
@@ -135,6 +135,8 @@ def play_playlist(self, playlist_id_or_name):
path = f"/playlists/{playlist['id']}/play"
return self._request("PUT", path)
+ raise ValueError(f"Playlist {playlist_id_or_name} not found")
+
def artwork_url(self):
"""Return a URL of the current track's album art."""
return f"{self._base_url}/artwork"
diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py
index 7564d571d3bcad..7fbfb17a976281 100644
--- a/homeassistant/components/jvc_projector/config_flow.py
+++ b/homeassistant/components/jvc_projector/config_flow.py
@@ -37,7 +37,7 @@ async def async_step_user(
try:
if not is_host_valid(host):
- raise InvalidHost
+ raise InvalidHost # noqa: TRY301
mac = await get_mac_address(host, port, password)
except InvalidHost:
diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json
index d3e1bf3d940f3e..5d83e93749481c 100644
--- a/homeassistant/components/jvc_projector/manifest.json
+++ b/homeassistant/components/jvc_projector/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
- "requirements": ["pyjvcprojector==1.0.11"]
+ "requirements": ["pyjvcprojector==1.0.12"]
}
diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py
index bb9f47ec1e8514..e4a562dc00b038 100644
--- a/homeassistant/components/kaleidescape/config_flow.py
+++ b/homeassistant/components/kaleidescape/config_flow.py
@@ -38,7 +38,7 @@ async def async_step_user(
try:
info = await validate_host(host)
if info.server_only:
- raise UnsupportedError
+ raise UnsupportedError # noqa: TRY301
except ConnectionError:
errors["base"] = ERROR_CANNOT_CONNECT
except UnsupportedError:
@@ -73,7 +73,7 @@ async def async_step_ssdp(
try:
self.discovered_device = await validate_host(host)
if self.discovered_device.server_only:
- raise UnsupportedError
+ raise UnsupportedError # noqa: TRY301
except ConnectionError:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
except UnsupportedError:
diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py
index 74c08933cbe187..1878a7f6e49b9a 100644
--- a/homeassistant/components/keba/sensor.py
+++ b/homeassistant/components/keba/sensor.py
@@ -64,7 +64,7 @@ async def async_setup_platform(
keba,
"session_energy",
SensorEntityDescription(
- key="E pres",
+ key="E pres", # codespell:ignore pres
name="Session Energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py
index ad335499ba4c6f..1c5188b1a6f399 100644
--- a/homeassistant/components/kef/media_player.py
+++ b/homeassistant/components/kef/media_player.py
@@ -161,7 +161,7 @@ async def async_setup_platform(
},
"set_mode",
)
- platform.async_register_entity_service(SERVICE_UPDATE_DSP, {}, "update_dsp")
+ platform.async_register_entity_service(SERVICE_UPDATE_DSP, None, "update_dsp")
def add_service(name, which, option):
options = DSP_OPTION_MAPPING[which]
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index f7e9b161962371..a401ee2ccac8d0 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
import contextlib
import logging
from pathlib import Path
@@ -148,18 +147,10 @@
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Start the KNX integration."""
hass.data[DATA_HASS_CONFIG] = config
- conf: ConfigType | None = config.get(DOMAIN)
-
- if conf is None:
- # If we have a config entry, setup is done by that config entry.
- # If there is no config entry, this should fail.
- return bool(hass.config_entries.async_entries(DOMAIN))
-
- conf = dict(conf)
- hass.data[DATA_KNX_CONFIG] = conf
+ if (conf := config.get(DOMAIN)) is not None:
+ hass.data[DATA_KNX_CONFIG] = dict(conf)
register_knx_services(hass)
-
return True
@@ -225,7 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
knx_module: KNXModule = hass.data[DOMAIN]
for exposure in knx_module.exposures:
- exposure.shutdown()
+ exposure.async_remove()
unload_ok = await hass.config_entries.async_unload_platforms(
entry,
@@ -303,7 +294,7 @@ def __init__(
self.entry = entry
self.project = KNXProject(hass=hass, entry=entry)
- self.config_store = KNXConfigStore(hass=hass, entry=entry)
+ self.config_store = KNXConfigStore(hass=hass, config_entry=entry)
self.xknx = XKNX(
connection_config=self.connection_config(),
@@ -334,7 +325,7 @@ def __init__(
async def start(self) -> None:
"""Start XKNX object. Connect to tunneling or Routing device."""
- await self.project.load_project()
+ await self.project.load_project(self.xknx)
await self.config_store.load_data()
await self.telegrams.load_history()
await self.xknx.start()
@@ -439,13 +430,13 @@ def connection_config(self) -> ConnectionConfig:
threaded=True,
)
- async def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
+ def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
"""Call invoked after a KNX connection state change was received."""
self.connected = state == XknxConnectionState.CONNECTED
- if tasks := [device.after_update() for device in self.xknx.devices]:
- await asyncio.gather(*tasks)
+ for device in self.xknx.devices:
+ device.after_update()
- async def telegram_received_cb(self, telegram: Telegram) -> None:
+ def telegram_received_cb(self, telegram: Telegram) -> None:
"""Call invoked after a KNX telegram was received."""
# Not all telegrams have serializable data.
data: int | tuple[int, ...] | None = None
@@ -504,10 +495,7 @@ def register_event_callback(self) -> TelegramQueue.Callback:
transcoder := DPTBase.parse_transcoder(dpt)
):
self._address_filter_transcoder.update(
- {
- _filter: transcoder # type: ignore[type-abstract]
- for _filter in _filters
- }
+ {_filter: transcoder for _filter in _filters}
)
return self.xknx.telegram_queue.register_telegram_received_cb(
diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py
index dee56608421dc1..7d80ca55bf6357 100644
--- a/homeassistant/components/knx/binary_sensor.py
+++ b/homeassistant/components/knx/binary_sensor.py
@@ -4,7 +4,6 @@
from typing import Any
-from xknx import XKNX
from xknx.devices import BinarySensor as XknxBinarySensor
from homeassistant import config_entries
@@ -23,8 +22,9 @@
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import ATTR_COUNTER, ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import BinarySensorSchema
@@ -34,25 +34,26 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the KNX binary sensor platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: ConfigType = hass.data[DATA_KNX_CONFIG]
async_add_entities(
- KNXBinarySensor(xknx, entity_config)
+ KNXBinarySensor(knx_module, entity_config)
for entity_config in config[Platform.BINARY_SENSOR]
)
-class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity):
+class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity):
"""Representation of a KNX binary sensor."""
_device: XknxBinarySensor
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX binary sensor."""
super().__init__(
+ knx_module=knx_module,
device=XknxBinarySensor(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS],
invert=config[BinarySensorSchema.CONF_INVERT],
@@ -62,7 +63,7 @@ def __init__(self, xknx: XKNX, config: ConfigType) -> None:
],
context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT),
reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER),
- )
+ ),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
@@ -75,7 +76,7 @@ async def async_added_to_hass(self) -> None:
if (
last_state := await self.async_get_last_state()
) and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
- await self._device.remote_value.update_value(last_state.state == STATE_ON)
+ self._device.remote_value.update_value(last_state.state == STATE_ON)
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py
index a38d8ad1b6cc53..f6627fc527b424 100644
--- a/homeassistant/components/knx/button.py
+++ b/homeassistant/components/knx/button.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from xknx import XKNX
from xknx.devices import RawValue as XknxRawValue
from homeassistant import config_entries
@@ -12,8 +11,9 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
async def async_setup_entry(
@@ -22,28 +22,30 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the KNX binary sensor platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: ConfigType = hass.data[DATA_KNX_CONFIG]
async_add_entities(
- KNXButton(xknx, entity_config) for entity_config in config[Platform.BUTTON]
+ KNXButton(knx_module, entity_config)
+ for entity_config in config[Platform.BUTTON]
)
-class KNXButton(KnxEntity, ButtonEntity):
+class KNXButton(KnxYamlEntity, ButtonEntity):
"""Representation of a KNX button."""
_device: XknxRawValue
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
super().__init__(
+ knx_module=knx_module,
device=XknxRawValue(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
- )
+ ),
)
self._payload = config[CONF_PAYLOAD]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py
index e1179641cdc5dd..4932df550873bd 100644
--- a/homeassistant/components/knx/climate.py
+++ b/homeassistant/components/knx/climate.py
@@ -5,12 +5,15 @@
from typing import Any
from xknx import XKNX
-from xknx.devices import Climate as XknxClimate, ClimateMode as XknxClimateMode
-from xknx.dpt.dpt_hvac_mode import HVACControllerMode, HVACOperationMode
+from xknx.devices import (
+ Climate as XknxClimate,
+ ClimateMode as XknxClimateMode,
+ Device as XknxDevice,
+)
+from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from homeassistant import config_entries
from homeassistant.components.climate import (
- PRESET_AWAY,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -27,19 +30,13 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
-from .const import (
- CONTROLLER_MODES,
- CURRENT_HVAC_ACTIONS,
- DATA_KNX_CONFIG,
- DOMAIN,
- PRESET_MODES,
-)
-from .knx_entity import KnxEntity
+from . import KNXModule
+from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, DATA_KNX_CONFIG, DOMAIN
+from .knx_entity import KnxYamlEntity
from .schema import ClimateSchema
ATTR_COMMAND_VALUE = "command_value"
CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
-PRESET_MODES_INV = {value: key for key, value in PRESET_MODES.items()}
async def async_setup_entry(
@@ -48,10 +45,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up climate(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.CLIMATE]
- async_add_entities(KNXClimate(xknx, entity_config) for entity_config in config)
+ async_add_entities(
+ KNXClimate(knx_module, entity_config) for entity_config in config
+ )
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
@@ -80,7 +79,7 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
group_address_operation_mode_protection=config.get(
ClimateSchema.CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS
),
- group_address_operation_mode_night=config.get(
+ group_address_operation_mode_economy=config.get(
ClimateSchema.CONF_OPERATION_MODE_NIGHT_ADDRESS
),
group_address_operation_mode_comfort=config.get(
@@ -130,16 +129,20 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
)
-class KNXClimate(KnxEntity, ClimateEntity):
+class KNXClimate(KnxYamlEntity, ClimateEntity):
"""Representation of a KNX climate device."""
_device: XknxClimate
_attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_translation_key = "knx_climate"
_enable_turn_on_off_backwards_compatibility = False
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
- super().__init__(_create_climate(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_climate(knx_module.xknx, config),
+ )
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
if self._device.supports_on_off:
@@ -155,8 +158,14 @@ def __init__(self, xknx: XKNX, config: ConfigType) -> None:
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
- if self.preset_modes:
+ if (
+ self._device.mode is not None
+ and self._device.mode.operation_modes # empty list when not writable
+ ):
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
+ self._attr_preset_modes = [
+ mode.name.lower() for mode in self._device.mode.operation_modes
+ ]
self._attr_target_temperature_step = self._device.temperature_step
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
@@ -199,10 +208,12 @@ async def async_turn_on(self) -> None:
self.async_write_ha_state()
return
- if self._device.mode is not None and self._device.mode.supports_controller_mode:
- knx_controller_mode = HVACControllerMode(
- CONTROLLER_MODES_INV.get(self._last_hvac_mode)
- )
+ if (
+ self._device.mode is not None
+ and self._device.mode.supports_controller_mode
+ and (knx_controller_mode := CONTROLLER_MODES_INV.get(self._last_hvac_mode))
+ is not None
+ ):
await self._device.mode.set_controller_mode(knx_controller_mode)
self.async_write_ha_state()
@@ -233,12 +244,9 @@ def hvac_mode(self) -> HVACMode:
if self._device.supports_on_off and not self._device.is_on:
return HVACMode.OFF
if self._device.mode is not None and self._device.mode.supports_controller_mode:
- hvac_mode = CONTROLLER_MODES.get(
- self._device.mode.controller_mode.value, self.default_hvac_mode
+ return CONTROLLER_MODES.get(
+ self._device.mode.controller_mode, self.default_hvac_mode
)
- if hvac_mode is not HVACMode.OFF:
- self._last_hvac_mode = hvac_mode
- return hvac_mode
return self.default_hvac_mode
@property
@@ -247,17 +255,21 @@ def hvac_modes(self) -> list[HVACMode]:
ha_controller_modes: list[HVACMode | None] = []
if self._device.mode is not None:
ha_controller_modes.extend(
- CONTROLLER_MODES.get(knx_controller_mode.value)
+ CONTROLLER_MODES.get(knx_controller_mode)
for knx_controller_mode in self._device.mode.controller_modes
)
if self._device.supports_on_off:
if not ha_controller_modes:
- ha_controller_modes.append(self.default_hvac_mode)
+ ha_controller_modes.append(self._last_hvac_mode)
ha_controller_modes.append(HVACMode.OFF)
hvac_modes = list(set(filter(None, ha_controller_modes)))
- return hvac_modes if hvac_modes else [self.default_hvac_mode]
+ return (
+ hvac_modes
+ if hvac_modes
+ else [self.hvac_mode] # mode read-only -> fall back to only current mode
+ )
@property
def hvac_action(self) -> HVACAction | None:
@@ -278,9 +290,7 @@ def hvac_action(self) -> HVACAction | None:
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set controller mode."""
if self._device.mode is not None and self._device.mode.supports_controller_mode:
- knx_controller_mode = HVACControllerMode(
- CONTROLLER_MODES_INV.get(hvac_mode)
- )
+ knx_controller_mode = CONTROLLER_MODES_INV.get(hvac_mode)
if knx_controller_mode in self._device.mode.controller_modes:
await self._device.mode.set_controller_mode(knx_controller_mode)
@@ -298,29 +308,18 @@ def preset_mode(self) -> str | None:
Requires ClimateEntityFeature.PRESET_MODE.
"""
if self._device.mode is not None and self._device.mode.supports_operation_mode:
- return PRESET_MODES.get(self._device.mode.operation_mode.value, PRESET_AWAY)
+ return self._device.mode.operation_mode.name.lower()
return None
- @property
- def preset_modes(self) -> list[str] | None:
- """Return a list of available preset modes.
-
- Requires ClimateEntityFeature.PRESET_MODE.
- """
- if self._device.mode is None:
- return None
-
- presets = [
- PRESET_MODES.get(operation_mode.value)
- for operation_mode in self._device.mode.operation_modes
- ]
- return list(filter(None, presets))
-
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
- if self._device.mode is not None and self._device.mode.supports_operation_mode:
- knx_operation_mode = HVACOperationMode(PRESET_MODES_INV.get(preset_mode))
- await self._device.mode.set_operation_mode(knx_operation_mode)
+ if (
+ self._device.mode is not None
+ and self._device.mode.operation_modes # empty list when not writable
+ ):
+ await self._device.mode.set_operation_mode(
+ HVACOperationMode[preset_mode.upper()]
+ )
self.async_write_ha_state()
@property
@@ -333,7 +332,25 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
return attr
async def async_added_to_hass(self) -> None:
- """Store register state change callback."""
+ """Store register state change callback and start device object."""
await super().async_added_to_hass()
if self._device.mode is not None:
self._device.mode.register_device_updated_cb(self.after_update_callback)
+ self._device.mode.xknx.devices.async_add(self._device.mode)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect device object when removed."""
+ if self._device.mode is not None:
+ self._device.mode.unregister_device_updated_cb(self.after_update_callback)
+ self._device.mode.xknx.devices.async_remove(self._device.mode)
+ await super().async_will_remove_from_hass()
+
+ def after_update_callback(self, _device: XknxDevice) -> None:
+ """Call after device was updated."""
+ if self._device.mode is not None and self._device.mode.supports_controller_mode:
+ hvac_mode = CONTROLLER_MODES.get(
+ self._device.mode.controller_mode, self.default_hvac_mode
+ )
+ if hvac_mode is not HVACMode.OFF:
+ self._last_hvac_mode = hvac_mode
+ super().after_update_callback(_device)
diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py
index 2fc1f49800c817..7e4db1f889b515 100644
--- a/homeassistant/components/knx/config_flow.py
+++ b/homeassistant/components/knx/config_flow.py
@@ -445,7 +445,7 @@ async def async_step_secure_routing_manual(
try:
key_bytes = bytes.fromhex(user_input[CONF_KNX_ROUTING_BACKBONE_KEY])
if len(key_bytes) != 16:
- raise ValueError
+ raise ValueError # noqa: TRY301
except ValueError:
errors[CONF_KNX_ROUTING_BACKBONE_KEY] = "invalid_backbone_key"
if not errors:
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index 0b7b517dca556f..9ceb18385cb783 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -6,17 +6,10 @@
from enum import Enum
from typing import Final, TypedDict
+from xknx.dpt.dpt_20 import HVACControllerMode
from xknx.telegram import Telegram
-from homeassistant.components.climate import (
- PRESET_AWAY,
- PRESET_COMFORT,
- PRESET_ECO,
- PRESET_NONE,
- PRESET_SLEEP,
- HVACAction,
- HVACMode,
-)
+from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.const import Platform
DOMAIN: Final = "knx"
@@ -158,12 +151,12 @@ class ColorTempModes(Enum):
# Map KNX controller modes to HA modes. This list might not be complete.
CONTROLLER_MODES: Final = {
# Map DPT 20.105 HVAC control modes
- "Auto": HVACMode.AUTO,
- "Heat": HVACMode.HEAT,
- "Cool": HVACMode.COOL,
- "Off": HVACMode.OFF,
- "Fan only": HVACMode.FAN_ONLY,
- "Dry": HVACMode.DRY,
+ HVACControllerMode.AUTO: HVACMode.AUTO,
+ HVACControllerMode.HEAT: HVACMode.HEAT,
+ HVACControllerMode.COOL: HVACMode.COOL,
+ HVACControllerMode.OFF: HVACMode.OFF,
+ HVACControllerMode.FAN_ONLY: HVACMode.FAN_ONLY,
+ HVACControllerMode.DEHUMIDIFICATION: HVACMode.DRY,
}
CURRENT_HVAC_ACTIONS: Final = {
@@ -173,12 +166,3 @@ class ColorTempModes(Enum):
HVACMode.FAN_ONLY: HVACAction.FAN,
HVACMode.DRY: HVACAction.DRYING,
}
-
-PRESET_MODES: Final = {
- # Map DPT 20.102 HVAC operating modes to HA presets
- "Auto": PRESET_NONE,
- "Frost Protection": PRESET_ECO,
- "Night": PRESET_SLEEP,
- "Standby": PRESET_AWAY,
- "Comfort": PRESET_COMFORT,
-}
diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py
index 9d86d6ac272984..408f746e0946dd 100644
--- a/homeassistant/components/knx/cover.py
+++ b/homeassistant/components/knx/cover.py
@@ -5,7 +5,6 @@
from collections.abc import Callable
from typing import Any
-from xknx import XKNX
from xknx.devices import Cover as XknxCover
from homeassistant import config_entries
@@ -26,8 +25,9 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import DATA_KNX_CONFIG, DOMAIN
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import CoverSchema
@@ -37,22 +37,23 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up cover(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.COVER]
- async_add_entities(KNXCover(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config)
-class KNXCover(KnxEntity, CoverEntity):
+class KNXCover(KnxYamlEntity, CoverEntity):
"""Representation of a KNX cover."""
_device: XknxCover
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize the cover."""
super().__init__(
+ knx_module=knx_module,
device=XknxCover(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
@@ -70,7 +71,7 @@ def __init__(self, xknx: XKNX, config: ConfigType) -> None:
invert_updown=config[CoverSchema.CONF_INVERT_UPDOWN],
invert_position=config[CoverSchema.CONF_INVERT_POSITION],
invert_angle=config[CoverSchema.CONF_INVERT_ANGLE],
- )
+ ),
)
self._unsubscribe_auto_updater: Callable[[], None] | None = None
diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py
index fa20a8d04c55cd..9f04a4acd7e3cf 100644
--- a/homeassistant/components/knx/date.py
+++ b/homeassistant/components/knx/date.py
@@ -3,11 +3,10 @@
from __future__ import annotations
from datetime import date as dt_date
-import time
-from typing import Final
from xknx import XKNX
-from xknx.devices import DateTime as XknxDateTime
+from xknx.devices import DateDevice as XknxDateDevice
+from xknx.dpt.dpt_11 import KNXDate as XKNXDate
from homeassistant import config_entries
from homeassistant.components.date import DateEntity
@@ -23,6 +22,7 @@
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
@@ -31,9 +31,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
-
-_DATE_TRANSLATION_FORMAT: Final = "%Y-%m-%d"
+from .knx_entity import KnxYamlEntity
async def async_setup_entry(
@@ -42,18 +40,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE]
- async_add_entities(KNXDate(xknx, entity_config) for entity_config in config)
+ async_add_entities(
+ KNXDateEntity(knx_module, entity_config) for entity_config in config
+ )
-def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
+def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
"""Return a XKNX DateTime object to be used within XKNX."""
- return XknxDateTime(
+ return XknxDateDevice(
xknx,
name=config[CONF_NAME],
- broadcast_type="DATE",
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
@@ -62,14 +61,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
)
-class KNXDate(KnxEntity, DateEntity, RestoreEntity):
+class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
"""Representation of a KNX date."""
- _device: XknxDateTime
+ _device: XknxDateDevice
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
- super().__init__(_create_xknx_device(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_xknx_device(knx_module.xknx, config),
+ )
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
@@ -81,21 +83,15 @@ async def async_added_to_hass(self) -> None:
and (last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
- self._device.remote_value.value = time.strptime(
- last_state.state, _DATE_TRANSLATION_FORMAT
+ self._device.remote_value.value = XKNXDate.from_date(
+ dt_date.fromisoformat(last_state.state)
)
@property
def native_value(self) -> dt_date | None:
"""Return the latest value."""
- if (time_struct := self._device.remote_value.value) is None:
- return None
- return dt_date(
- year=time_struct.tm_year,
- month=time_struct.tm_mon,
- day=time_struct.tm_mday,
- )
+ return self._device.value
async def async_set_value(self, value: dt_date) -> None:
"""Change the value."""
- await self._device.set(value.timetuple())
+ await self._device.set(value)
diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py
index 2a1a9e2f9c978a..8f1a25e6e3cc9c 100644
--- a/homeassistant/components/knx/datetime.py
+++ b/homeassistant/components/knx/datetime.py
@@ -5,7 +5,8 @@
from datetime import datetime
from xknx import XKNX
-from xknx.devices import DateTime as XknxDateTime
+from xknx.devices import DateTimeDevice as XknxDateTimeDevice
+from xknx.dpt.dpt_19 import KNXDateTime as XKNXDateTime
from homeassistant import config_entries
from homeassistant.components.datetime import DateTimeEntity
@@ -22,6 +23,7 @@
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
+from . import KNXModule
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
@@ -30,7 +32,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
async def async_setup_entry(
@@ -39,18 +41,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATETIME]
- async_add_entities(KNXDateTime(xknx, entity_config) for entity_config in config)
+ async_add_entities(
+ KNXDateTimeEntity(knx_module, entity_config) for entity_config in config
+ )
-def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
+def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice:
"""Return a XKNX DateTime object to be used within XKNX."""
- return XknxDateTime(
+ return XknxDateTimeDevice(
xknx,
name=config[CONF_NAME],
- broadcast_type="DATETIME",
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
@@ -59,14 +62,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
)
-class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity):
+class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
"""Representation of a KNX datetime."""
- _device: XknxDateTime
+ _device: XknxDateTimeDevice
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
- super().__init__(_create_xknx_device(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_xknx_device(knx_module.xknx, config),
+ )
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
@@ -78,29 +84,19 @@ async def async_added_to_hass(self) -> None:
and (last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
- self._device.remote_value.value = (
- datetime.fromisoformat(last_state.state)
- .astimezone(dt_util.get_default_time_zone())
- .timetuple()
+ self._device.remote_value.value = XKNXDateTime.from_datetime(
+ datetime.fromisoformat(last_state.state).astimezone(
+ dt_util.get_default_time_zone()
+ )
)
@property
def native_value(self) -> datetime | None:
"""Return the latest value."""
- if (time_struct := self._device.remote_value.value) is None:
+ if (naive_dt := self._device.value) is None:
return None
- return datetime(
- year=time_struct.tm_year,
- month=time_struct.tm_mon,
- day=time_struct.tm_mday,
- hour=time_struct.tm_hour,
- minute=time_struct.tm_min,
- second=min(time_struct.tm_sec, 59), # account for leap seconds
- tzinfo=dt_util.get_default_time_zone(),
- )
+ return naive_dt.replace(tzinfo=dt_util.get_default_time_zone())
async def async_set_value(self, value: datetime) -> None:
"""Change the value."""
- await self._device.set(
- value.astimezone(dt_util.get_default_time_zone()).timetuple()
- )
+ await self._device.set(value.astimezone(dt_util.get_default_time_zone()))
diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py
index fd5abc6a072a65..b43b5926d865c7 100644
--- a/homeassistant/components/knx/device.py
+++ b/homeassistant/components/knx/device.py
@@ -19,6 +19,7 @@ class KNXInterfaceDevice:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, xknx: XKNX) -> None:
"""Initialize interface device class."""
+ self.hass = hass
self.device_registry = dr.async_get(hass)
self.gateway_descriptor: GatewayDescriptor | None = None
self.xknx = xknx
@@ -46,7 +47,7 @@ async def update(self) -> None:
else None,
)
- async def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
+ def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
"""Call invoked after a KNX connection state change was received."""
if state is XknxConnectionState.CONNECTED:
- await self.update()
+ self.hass.async_create_task(self.update())
diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py
index 695fe3b3851476..921af6ba4a91d1 100644
--- a/homeassistant/components/knx/expose.py
+++ b/homeassistant/components/knx/expose.py
@@ -6,7 +6,7 @@
import logging
from xknx import XKNX
-from xknx.devices import DateTime, ExposeSensor
+from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
from xknx.dpt import DPTNumeric, DPTString
from xknx.exceptions import ConversionError
from xknx.remote_value import RemoteValueSensor
@@ -60,6 +60,7 @@ def create_knx_exposure(
xknx=xknx,
config=config,
)
+ exposure.async_register()
return exposure
@@ -83,29 +84,25 @@ def __init__(
self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- if self.value_template is not None:
- self.value_template.hass = hass
self._remove_listener: Callable[[], None] | None = None
- self.device: ExposeSensor = self.async_register(config)
- self._init_expose_state()
-
- @callback
- def async_register(self, config: ConfigType) -> ExposeSensor:
- """Register listener."""
- name = f"{self.entity_id}__{self.expose_attribute or "state"}"
- device = ExposeSensor(
+ self.device: ExposeSensor = ExposeSensor(
xknx=self.xknx,
- name=name,
+ name=f"{self.entity_id}__{self.expose_attribute or "state"}",
group_address=config[KNX_ADDRESS],
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=self.expose_type,
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
)
+
+ @callback
+ def async_register(self) -> None:
+ """Register listener."""
self._remove_listener = async_track_state_change_event(
self.hass, [self.entity_id], self._async_entity_changed
)
- return device
+ self.xknx.devices.async_add(self.device)
+ self._init_expose_state()
@callback
def _init_expose_state(self) -> None:
@@ -118,12 +115,12 @@ def _init_expose_state(self) -> None:
_LOGGER.exception("Error during sending of expose sensor value")
@callback
- def shutdown(self) -> None:
+ def async_remove(self) -> None:
"""Prepare for deletion."""
if self._remove_listener is not None:
self._remove_listener()
self._remove_listener = None
- self.device.shutdown()
+ self.xknx.devices.async_remove(self.device)
def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
"""Extract value from state."""
@@ -196,21 +193,28 @@ class KNXExposeTime:
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
"""Initialize of Expose class."""
self.xknx = xknx
- self.device: DateTime = self.async_register(config)
-
- @callback
- def async_register(self, config: ConfigType) -> DateTime:
- """Register listener."""
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
- return DateTime(
+ xknx_device_cls: type[DateDevice | DateTimeDevice | TimeDevice]
+ match expose_type:
+ case ExposeSchema.CONF_DATE:
+ xknx_device_cls = DateDevice
+ case ExposeSchema.CONF_DATETIME:
+ xknx_device_cls = DateTimeDevice
+ case ExposeSchema.CONF_TIME:
+ xknx_device_cls = TimeDevice
+ self.device = xknx_device_cls(
self.xknx,
name=expose_type.capitalize(),
- broadcast_type=expose_type.upper(),
localtime=True,
group_address=config[KNX_ADDRESS],
)
@callback
- def shutdown(self) -> None:
+ def async_register(self) -> None:
+ """Register listener."""
+ self.xknx.devices.async_add(self.device)
+
+ @callback
+ def async_remove(self) -> None:
"""Prepare for deletion."""
- self.device.shutdown()
+ self.xknx.devices.async_remove(self.device)
diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py
index 426a750f766747..6fd87be97d11dd 100644
--- a/homeassistant/components/knx/fan.py
+++ b/homeassistant/components/knx/fan.py
@@ -5,7 +5,6 @@
import math
from typing import Any, Final
-from xknx import XKNX
from xknx.devices import Fan as XknxFan
from homeassistant import config_entries
@@ -20,8 +19,9 @@
)
from homeassistant.util.scaling import int_states_in_range
+from . import KNXModule
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import FanSchema
DEFAULT_PERCENTAGE: Final = 50
@@ -33,24 +33,25 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up fan(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.FAN]
- async_add_entities(KNXFan(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
-class KNXFan(KnxEntity, FanEntity):
+class KNXFan(KnxYamlEntity, FanEntity):
"""Representation of a KNX fan."""
_device: XknxFan
_enable_turn_on_off_backwards_compatibility = False
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanSchema.CONF_MAX_STEP)
super().__init__(
+ knx_module=knx_module,
device=XknxFan(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
@@ -61,7 +62,7 @@ def __init__(self, xknx: XKNX, config: ConfigType) -> None:
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
max_step=max_step,
- )
+ ),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
diff --git a/homeassistant/components/knx/icons.json b/homeassistant/components/knx/icons.json
index 736923375ee24f..2aee34219f66f4 100644
--- a/homeassistant/components/knx/icons.json
+++ b/homeassistant/components/knx/icons.json
@@ -1,5 +1,19 @@
{
"entity": {
+ "climate": {
+ "knx_climate": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "comfort": "mdi:sofa",
+ "standby": "mdi:home-export-outline",
+ "economy": "mdi:leaf",
+ "building_protection": "mdi:sun-snowflake-variant"
+ }
+ }
+ }
+ }
+ },
"sensor": {
"individual_address": {
"default": "mdi:router-network"
diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/knx_entity.py
index b03c59486e5a6c..c81a6ee06db872 100644
--- a/homeassistant/components/knx/knx_entity.py
+++ b/homeassistant/components/knx/knx_entity.py
@@ -2,24 +2,55 @@
from __future__ import annotations
-from typing import cast
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_platform import EntityPlatform
+from homeassistant.helpers.entity_registry import RegistryEntry
-from . import KNXModule
-from .const import DOMAIN
+if TYPE_CHECKING:
+ from . import KNXModule
+from .storage.config_store import PlatformControllerBase
-class KnxEntity(Entity):
+
+class KnxUiEntityPlatformController(PlatformControllerBase):
+ """Class to manage dynamic adding and reloading of UI entities."""
+
+ def __init__(
+ self,
+ knx_module: KNXModule,
+ entity_platform: EntityPlatform,
+ entity_class: type[KnxUiEntity],
+ ) -> None:
+ """Initialize the UI platform."""
+ self._knx_module = knx_module
+ self._entity_platform = entity_platform
+ self._entity_class = entity_class
+
+ async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
+ """Add a new UI entity."""
+ await self._entity_platform.async_add_entities(
+ [self._entity_class(self._knx_module, unique_id, config)]
+ )
+
+ async def update_entity(
+ self, entity_entry: RegistryEntry, config: dict[str, Any]
+ ) -> None:
+ """Update an existing UI entities configuration."""
+ await self._entity_platform.async_remove_entity(entity_entry.entity_id)
+ await self.create_entity(unique_id=entity_entry.unique_id, config=config)
+
+
+class _KnxEntityBase(Entity):
"""Representation of a KNX entity."""
_attr_should_poll = False
-
- def __init__(self, device: XknxDevice) -> None:
- """Set up device."""
- self._device = device
+ _knx_module: KNXModule
+ _device: XknxDevice
@property
def name(self) -> str:
@@ -29,19 +60,46 @@ def name(self) -> str:
@property
def available(self) -> bool:
"""Return True if entity is available."""
- knx_module = cast(KNXModule, self.hass.data[DOMAIN])
- return knx_module.connected
+ return self._knx_module.connected
async def async_update(self) -> None:
"""Request a state update from KNX bus."""
await self._device.sync()
- async def after_update_callback(self, device: XknxDevice) -> None:
+ def after_update_callback(self, _device: XknxDevice) -> None:
"""Call after device was updated."""
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
- """Store register state change callback."""
+ """Store register state change callback and start device object."""
self._device.register_device_updated_cb(self.after_update_callback)
- # will remove all callbacks and xknx tasks
- self.async_on_remove(self._device.shutdown)
+ self._device.xknx.devices.async_add(self._device)
+ # super call needed to have methods of multi-inherited classes called
+ # eg. for restoring state (like _KNXSwitch)
+ await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect device object when removed."""
+ self._device.unregister_device_updated_cb(self.after_update_callback)
+ self._device.xknx.devices.async_remove(self._device)
+
+
+class KnxYamlEntity(_KnxEntityBase):
+ """Representation of a KNX entity configured from YAML."""
+
+ def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
+ """Initialize the YAML entity."""
+ self._knx_module = knx_module
+ self._device = device
+
+
+class KnxUiEntity(_KnxEntityBase, ABC):
+ """Representation of a KNX UI entity."""
+
+ _attr_unique_id: str
+
+ @abstractmethod
+ def __init__(
+ self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
+ ) -> None:
+ """Initialize the UI entity."""
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
index 425640a9915935..0caa3f0a7992a1 100644
--- a/homeassistant/components/knx/light.py
+++ b/homeassistant/components/knx/light.py
@@ -19,15 +19,18 @@
LightEntity,
)
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddEntitiesCallback,
+ async_get_current_platform,
+)
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.color as color_util
from . import KNXModule
from .const import CONF_SYNC_STATE, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes
-from .knx_entity import KnxEntity
+from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import LightSchema
from .storage.const import (
CONF_COLOR_TEMP_MAX,
@@ -63,12 +66,21 @@ async def async_setup_entry(
) -> None:
"""Set up light(s) for KNX platform."""
knx_module: KNXModule = hass.data[DOMAIN]
+ platform = async_get_current_platform()
+ knx_module.config_store.add_platform(
+ platform=Platform.LIGHT,
+ controller=KnxUiEntityPlatformController(
+ knx_module=knx_module,
+ entity_platform=platform,
+ entity_class=KnxUiLight,
+ ),
+ )
- entities: list[KnxEntity] = []
- if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT):
+ entities: list[KnxYamlEntity | KnxUiEntity] = []
+ if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT):
entities.extend(
- KnxYamlLight(knx_module.xknx, entity_config)
- for entity_config in yaml_config
+ KnxYamlLight(knx_module, entity_config)
+ for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT):
entities.extend(
@@ -78,13 +90,6 @@ async def async_setup_entry(
if entities:
async_add_entities(entities)
- @callback
- def add_new_ui_light(unique_id: str, config: dict[str, Any]) -> None:
- """Add KNX entity at runtime."""
- async_add_entities([KnxUiLight(knx_module, unique_id, config)])
-
- knx_module.config_store.async_add_entity[Platform.LIGHT] = add_new_ui_light
-
def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight:
"""Return a KNX Light device to be used within XKNX."""
@@ -221,7 +226,7 @@ def get_dpt(key: str) -> str | None:
group_address_color_temp_state = None
color_temperature_type = ColorTemperatureType.UINT_2_BYTE
if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP):
- if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE:
+ if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value:
group_address_tunable_white = ga_color_temp[CONF_GA_WRITE]
group_address_tunable_white_state = [
ga_color_temp[CONF_GA_STATE],
@@ -234,7 +239,7 @@ def get_dpt(key: str) -> str | None:
ga_color_temp[CONF_GA_STATE],
*ga_color_temp[CONF_GA_PASSIVE],
]
- if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT:
+ if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value:
color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE
_color_dpt = get_dpt(CONF_GA_COLOR)
@@ -294,7 +299,7 @@ def get_dpt(key: str) -> str | None:
)
-class _KnxLight(KnxEntity, LightEntity):
+class _KnxLight(LightEntity):
"""Representation of a KNX light."""
_attr_max_color_temp_kelvin: int
@@ -312,8 +317,7 @@ def brightness(self) -> int | None:
if self._device.supports_brightness:
return self._device.current_brightness
if self._device.current_xyy_color is not None:
- _, brightness = self._device.current_xyy_color
- return brightness
+ return self._device.current_xyy_color.brightness
if self._device.supports_color or self._device.supports_rgbw:
rgb, white = self._device.current_color
if rgb is None:
@@ -363,8 +367,7 @@ def hs_color(self) -> tuple[float, float] | None:
def xy_color(self) -> tuple[float, float] | None:
"""Return the xy color value [float, float]."""
if self._device.current_xyy_color is not None:
- xy_color, _ = self._device.current_xyy_color
- return xy_color
+ return self._device.current_xyy_color.color
return None
@property
@@ -521,14 +524,17 @@ async def async_turn_off(self, **kwargs: Any) -> None:
await self._device.set_off()
-class KnxYamlLight(_KnxLight):
+class KnxYamlLight(_KnxLight, KnxYamlEntity):
"""Representation of a KNX light."""
_device: XknxLight
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX light."""
- super().__init__(_create_yaml_light(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_yaml_light(knx_module.xknx, config),
+ )
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@@ -545,20 +551,19 @@ def _device_unique_id(self) -> str:
)
-class KnxUiLight(_KnxLight):
+class KnxUiLight(_KnxLight, KnxUiEntity):
"""Representation of a KNX light."""
- _device: XknxLight
_attr_has_entity_name = True
+ _device: XknxLight
def __init__(
self, knx_module: KNXModule, unique_id: str, config: ConfigType
) -> None:
"""Initialize of KNX light."""
- super().__init__(
- _create_ui_light(
- knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
- )
+ self._knx_module = knx_module
+ self._device = _create_ui_light(
+ knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
)
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]
@@ -567,5 +572,3 @@ def __init__(
self._attr_unique_id = unique_id
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})
-
- knx_module.config_store.entities[unique_id] = self
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 5035239d1fb140..9ecf687d6b9d7a 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -11,9 +11,9 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
- "xknx==2.12.2",
+ "xknx==3.1.0",
"xknxproject==3.7.1",
- "knx-frontend==2024.7.25.204106"
+ "knx-frontend==2024.8.9.225351"
],
"single_config_entry": true
}
diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py
index 997bdb81057b80..173ab3119a0faf 100644
--- a/homeassistant/components/knx/notify.py
+++ b/homeassistant/components/knx/notify.py
@@ -18,8 +18,9 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from . import KNXModule
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
async def async_get_service(
@@ -44,7 +45,7 @@ async def async_get_service(
class KNXNotificationService(BaseNotificationService):
- """Implement demo notification service."""
+ """Implement notification service."""
def __init__(self, devices: list[XknxNotification]) -> None:
"""Initialize the service."""
@@ -86,10 +87,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up notify(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NOTIFY]
- async_add_entities(KNXNotify(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXNotify(knx_module, entity_config) for entity_config in config)
def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotification:
@@ -102,14 +103,17 @@ def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotific
)
-class KNXNotify(KnxEntity, NotifyEntity):
+class KNXNotify(KnxYamlEntity, NotifyEntity):
"""Representation of a KNX notification entity."""
_device: XknxNotification
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX notification."""
- super().__init__(_create_notification_instance(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_notification_instance(knx_module.xknx, config),
+ )
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py
index 8a9f1dea87c2cb..cbbe91aba54c31 100644
--- a/homeassistant/components/knx/number.py
+++ b/homeassistant/components/knx/number.py
@@ -22,6 +22,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
@@ -29,7 +30,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import NumberSchema
@@ -39,10 +40,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up number(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NUMBER]
- async_add_entities(KNXNumber(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXNumber(knx_module, entity_config) for entity_config in config)
def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
@@ -57,14 +58,17 @@ def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
)
-class KNXNumber(KnxEntity, RestoreNumber):
+class KNXNumber(KnxYamlEntity, RestoreNumber):
"""Representation of a KNX number."""
_device: NumericValue
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
- super().__init__(_create_numeric_value(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_numeric_value(knx_module.xknx, config),
+ )
self._attr_native_max_value = config.get(
NumberSchema.CONF_MAX,
self._device.sensor_value.dpt_class.value_max,
diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py
index 13e71dbbe38f8c..b5bafe0072489f 100644
--- a/homeassistant/components/knx/project.py
+++ b/homeassistant/components/knx/project.py
@@ -6,10 +6,13 @@
import logging
from typing import Final
+from xknx import XKNX
from xknx.dpt import DPTBase
+from xknx.telegram.address import DeviceAddressableType
from xknxproject import XKNXProj
from xknxproject.models import (
Device,
+ DPTType,
GroupAddress as GroupAddressModel,
KNXProject as KNXProjectModel,
ProjectInfo,
@@ -80,15 +83,23 @@ def initial_state(self) -> None:
self.group_addresses = {}
self.info = None
- async def load_project(self, data: KNXProjectModel | None = None) -> None:
+ async def load_project(
+ self, xknx: XKNX, data: KNXProjectModel | None = None
+ ) -> None:
"""Load project data from storage."""
if project := data or await self._store.async_load():
self.devices = project["devices"]
self.info = project["info"]
+ xknx.group_address_dpt.clear()
+ xknx_ga_dict: dict[DeviceAddressableType, DPTType] = {}
for ga_model in project["group_addresses"].values():
ga_info = _create_group_address_info(ga_model)
self.group_addresses[ga_info.address] = ga_info
+ if (dpt_model := ga_model.get("dpt")) is not None:
+ xknx_ga_dict[ga_model["address"]] = dpt_model
+
+ xknx.group_address_dpt.set(xknx_ga_dict)
_LOGGER.debug(
"Loaded KNX project data with %s group addresses from storage",
@@ -96,7 +107,9 @@ async def load_project(self, data: KNXProjectModel | None = None) -> None:
)
self.loaded = True
- async def process_project_file(self, file_id: str, password: str) -> None:
+ async def process_project_file(
+ self, xknx: XKNX, file_id: str, password: str
+ ) -> None:
"""Process an uploaded project file."""
def _parse_project() -> KNXProjectModel:
@@ -110,7 +123,7 @@ def _parse_project() -> KNXProjectModel:
project = await self.hass.async_add_executor_job(_parse_project)
await self._store.async_save(project)
- await self.load_project(data=project)
+ await self.load_project(xknx, data=project)
async def remove_project_file(self) -> None:
"""Remove project file from storage."""
diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py
index 342d0f9eb83a04..2de832ae54aee3 100644
--- a/homeassistant/components/knx/scene.py
+++ b/homeassistant/components/knx/scene.py
@@ -4,7 +4,6 @@
from typing import Any
-from xknx import XKNX
from xknx.devices import Scene as XknxScene
from homeassistant import config_entries
@@ -14,8 +13,9 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import SceneSchema
@@ -25,26 +25,27 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up scene(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SCENE]
- async_add_entities(KNXScene(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config)
-class KNXScene(KnxEntity, Scene):
+class KNXScene(KnxYamlEntity, Scene):
"""Representation of a KNX scene."""
_device: XknxScene
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Init KNX scene."""
super().__init__(
+ knx_module=knx_module,
device=XknxScene(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
- )
+ ),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py
index 34a145eadb36e7..c31b3d30ad0693 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -9,6 +9,7 @@
import voluptuous as vol
from xknx.devices.climate import SetpointShiftMode
from xknx.dpt import DPTBase, DPTNumeric
+from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from xknx.exceptions import ConversionError, CouldNotParseTelegram
from homeassistant.components.binary_sensor import (
@@ -51,12 +52,12 @@
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
- CONTROLLER_MODES,
KNX_ADDRESS,
- PRESET_MODES,
ColorTempModes,
)
from .validation import (
+ backwards_compatible_xknx_climate_enum_member,
+ dpt_base_type_validator,
ga_list_validator,
ga_validator,
numeric_type_validator,
@@ -173,7 +174,7 @@ class EventSchema:
KNX_EVENT_FILTER_SCHEMA = vol.Schema(
{
vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_TYPE): sensor_type_validator,
+ vol.Optional(CONF_TYPE): dpt_base_type_validator,
}
)
@@ -409,10 +410,12 @@ class ClimateSchema(KNXPlatformSchema):
CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
): cv.boolean,
vol.Optional(CONF_OPERATION_MODES): vol.All(
- cv.ensure_list, [vol.In(PRESET_MODES)]
+ cv.ensure_list,
+ [backwards_compatible_xknx_climate_enum_member(HVACOperationMode)],
),
vol.Optional(CONF_CONTROLLER_MODES): vol.All(
- cv.ensure_list, [vol.In(CONTROLLER_MODES)]
+ cv.ensure_list,
+ [backwards_compatible_xknx_climate_enum_member(HVACControllerMode)],
),
vol.Optional(
CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
@@ -535,11 +538,10 @@ class ExposeSchema(KNXPlatformSchema):
CONF_KNX_EXPOSE_BINARY = "binary"
CONF_KNX_EXPOSE_COOLDOWN = "cooldown"
CONF_KNX_EXPOSE_DEFAULT = "default"
- EXPOSE_TIME_TYPES: Final = [
- "time",
- "date",
- "datetime",
- ]
+ CONF_TIME = "time"
+ CONF_DATE = "date"
+ CONF_DATETIME = "datetime"
+ EXPOSE_TIME_TYPES: Final = [CONF_TIME, CONF_DATE, CONF_DATETIME]
EXPOSE_TIME_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py
index 5d7532e0e5da35..6c73bf8d573480 100644
--- a/homeassistant/components/knx/select.py
+++ b/homeassistant/components/knx/select.py
@@ -20,6 +20,7 @@
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import (
CONF_PAYLOAD_LENGTH,
CONF_RESPOND_TO_READ,
@@ -29,7 +30,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import SelectSchema
@@ -39,10 +40,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up select(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SELECT]
- async_add_entities(KNXSelect(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXSelect(knx_module, entity_config) for entity_config in config)
def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue:
@@ -58,14 +59,17 @@ def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue:
)
-class KNXSelect(KnxEntity, SelectEntity, RestoreEntity):
+class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
"""Representation of a KNX select."""
_device: RawValue
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX select."""
- super().__init__(_create_raw_value(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_raw_value(knx_module.xknx, config),
+ )
self._option_payloads: dict[str, int] = {
option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD]
for option in config[SelectSchema.CONF_OPTIONS]
@@ -81,17 +85,18 @@ async def async_added_to_hass(self) -> None:
if not self._device.remote_value.readable and (
last_state := await self.async_get_last_state()
):
- if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
- await self._device.remote_value.update_value(
- self._option_payloads.get(last_state.state)
- )
+ if (
+ last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
+ and (option := self._option_payloads.get(last_state.state)) is not None
+ ):
+ self._device.remote_value.update_value(option)
- async def after_update_callback(self, device: XknxDevice) -> None:
+ def after_update_callback(self, device: XknxDevice) -> None:
"""Call after device was updated."""
self._attr_current_option = self.option_from_payload(
self._device.remote_value.value
)
- await super().after_update_callback(device)
+ super().after_update_callback(device)
def option_from_payload(self, payload: int | None) -> str | None:
"""Return the option a given payload is assigned to."""
diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py
index 173979f78dcf33..a28c1a339e650b 100644
--- a/homeassistant/components/knx/sensor.py
+++ b/homeassistant/components/knx/sensor.py
@@ -35,7 +35,7 @@
from . import KNXModule
from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import SensorSchema
SCAN_INTERVAL = timedelta(seconds=10)
@@ -116,17 +116,17 @@ async def async_setup_entry(
) -> None:
"""Set up sensor(s) for KNX platform."""
knx_module: KNXModule = hass.data[DOMAIN]
-
- async_add_entities(
+ entities: list[SensorEntity] = []
+ entities.extend(
KNXSystemSensor(knx_module, description)
for description in SYSTEM_ENTITY_DESCRIPTIONS
)
-
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR)
if config:
- async_add_entities(
- KNXSensor(knx_module.xknx, entity_config) for entity_config in config
+ entities.extend(
+ KNXSensor(knx_module, entity_config) for entity_config in config
)
+ async_add_entities(entities)
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
@@ -141,14 +141,17 @@ def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
)
-class KNXSensor(KnxEntity, SensorEntity):
+class KNXSensor(KnxYamlEntity, SensorEntity):
"""Representation of a KNX sensor."""
_device: XknxSensor
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
- super().__init__(_create_sensor(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_sensor(knx_module.xknx, config),
+ )
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
@@ -208,7 +211,7 @@ def available(self) -> bool:
return True
return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED
- async def after_update_callback(self, _: XknxConnectionState) -> None:
+ def after_update_callback(self, _: XknxConnectionState) -> None:
"""Call after device was updated."""
self.async_write_ha_state()
diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py
index 24b9452cf60ddf..8b82671deaa12b 100644
--- a/homeassistant/components/knx/services.py
+++ b/homeassistant/components/knx/services.py
@@ -8,6 +8,7 @@
import voluptuous as vol
from xknx.dpt import DPTArray, DPTBase, DPTBinary
+from xknx.exceptions import ConversionError
from xknx.telegram import Telegram
from xknx.telegram.address import parse_device_group_address
from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
@@ -31,7 +32,7 @@
SERVICE_KNX_SEND,
)
from .expose import create_knx_exposure
-from .schema import ExposeSchema, ga_validator, sensor_type_validator
+from .schema import ExposeSchema, dpt_base_type_validator, ga_validator
if TYPE_CHECKING:
from . import KNXModule
@@ -95,7 +96,7 @@ def get_knx_module(hass: HomeAssistant) -> KNXModule:
cv.ensure_list,
[ga_validator],
),
- vol.Optional(CONF_TYPE): sensor_type_validator,
+ vol.Optional(CONF_TYPE): dpt_base_type_validator,
vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean,
}
)
@@ -125,10 +126,7 @@ async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall)
transcoder := DPTBase.parse_transcoder(dpt)
):
knx_module.group_address_transcoder.update(
- {
- _address: transcoder # type: ignore[type-abstract]
- for _address in group_addresses
- }
+ {_address: transcoder for _address in group_addresses}
)
for group_address in group_addresses:
if group_address in knx_module.knx_event_callback.group_addresses:
@@ -173,7 +171,7 @@ async def service_exposure_register_modify(
f"Could not find exposure for '{group_address}' to remove."
) from err
- removed_exposure.shutdown()
+ removed_exposure.async_remove()
return
if group_address in knx_module.service_exposures:
@@ -186,7 +184,7 @@ async def service_exposure_register_modify(
group_address,
replaced_exposure.device.name,
)
- replaced_exposure.shutdown()
+ replaced_exposure.async_remove()
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
knx_module.service_exposures[group_address] = exposure
_LOGGER.debug(
@@ -204,7 +202,7 @@ async def service_exposure_register_modify(
[ga_validator],
),
vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all,
- vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator,
+ vol.Required(SERVICE_KNX_ATTR_TYPE): dpt_base_type_validator,
vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean,
}
),
@@ -237,8 +235,15 @@ async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> Non
if attr_type is not None:
transcoder = DPTBase.parse_transcoder(attr_type)
if transcoder is None:
- raise ValueError(f"Invalid type for knx.send service: {attr_type}")
- payload = transcoder.to_knx(attr_payload)
+ raise ServiceValidationError(
+ f"Invalid type for knx.send service: {attr_type}"
+ )
+ try:
+ payload = transcoder.to_knx(attr_payload)
+ except ConversionError as err:
+ raise ServiceValidationError(
+ f"Invalid payload for knx.send service: {err}"
+ ) from err
elif isinstance(attr_payload, int):
payload = DPTBinary(attr_payload)
else:
diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py
index 7ea61e1dd3e63b..ce7a705e629a1b 100644
--- a/homeassistant/components/knx/storage/config_store.py
+++ b/homeassistant/components/knx/storage/config_store.py
@@ -1,8 +1,8 @@
"""KNX entity configuration store."""
-from collections.abc import Callable
+from abc import ABC, abstractmethod
import logging
-from typing import TYPE_CHECKING, Any, Final, TypedDict
+from typing import Any, Final, TypedDict
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, Platform
@@ -14,9 +14,6 @@
from ..const import DOMAIN
from .const import CONF_DATA
-if TYPE_CHECKING:
- from ..knx_entity import KnxEntity
-
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION: Final = 1
@@ -34,24 +31,34 @@ class KNXConfigStoreModel(TypedDict):
entities: KNXEntityStoreModel
+class PlatformControllerBase(ABC):
+ """Entity platform controller base class."""
+
+ @abstractmethod
+ async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
+ """Create a new entity."""
+
+ @abstractmethod
+ async def update_entity(
+ self, entity_entry: er.RegistryEntry, config: dict[str, Any]
+ ) -> None:
+ """Update an existing entities configuration."""
+
+
class KNXConfigStore:
"""Manage KNX config store data."""
def __init__(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: ConfigEntry,
) -> None:
"""Initialize config store."""
self.hass = hass
+ self.config_entry = config_entry
self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY)
self.data = KNXConfigStoreModel(entities={})
-
- # entities and async_add_entity are filled by platform setups
- self.entities: dict[str, KnxEntity] = {} # unique_id as key
- self.async_add_entity: dict[
- Platform, Callable[[str, dict[str, Any]], None]
- ] = {}
+ self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
async def load_data(self) -> None:
"""Load config store data from storage."""
@@ -62,14 +69,19 @@ async def load_data(self) -> None:
len(self.data["entities"]),
)
+ def add_platform(
+ self, platform: Platform, controller: PlatformControllerBase
+ ) -> None:
+ """Add platform controller."""
+ self._platform_controllers[platform] = controller
+
async def create_entity(
self, platform: Platform, data: dict[str, Any]
) -> str | None:
"""Create a new entity."""
- if platform not in self.async_add_entity:
- raise ConfigStoreException(f"Entity platform not ready: {platform}")
+ platform_controller = self._platform_controllers[platform]
unique_id = f"knx_es_{ulid_now()}"
- self.async_add_entity[platform](unique_id, data)
+ await platform_controller.create_entity(unique_id, data)
# store data after entity was added to be sure config didn't raise exceptions
self.data["entities"].setdefault(platform, {})[unique_id] = data
await self._store.async_save(self.data)
@@ -95,8 +107,7 @@ async def update_entity(
self, platform: Platform, entity_id: str, data: dict[str, Any]
) -> None:
"""Update an existing entity."""
- if platform not in self.async_add_entity:
- raise ConfigStoreException(f"Entity platform not ready: {platform}")
+ platform_controller = self._platform_controllers[platform]
entity_registry = er.async_get(self.hass)
if (entry := entity_registry.async_get(entity_id)) is None:
raise ConfigStoreException(f"Entity not found: {entity_id}")
@@ -108,8 +119,7 @@ async def update_entity(
raise ConfigStoreException(
f"Entity not found in storage: {entity_id} - {unique_id}"
)
- await self.entities.pop(unique_id).async_remove()
- self.async_add_entity[platform](unique_id, data)
+ await platform_controller.update_entity(entry, data)
# store data after entity is added to make sure config doesn't raise exceptions
self.data["entities"][platform][unique_id] = data
await self._store.async_save(self.data)
@@ -125,19 +135,21 @@ async def delete_entity(self, entity_id: str) -> None:
raise ConfigStoreException(
f"Entity not found in {entry.domain}: {entry.unique_id}"
) from err
- try:
- del self.entities[entry.unique_id]
- except KeyError:
- _LOGGER.warning("Entity not initialized when deleted: %s", entity_id)
entity_registry.async_remove(entity_id)
await self._store.async_save(self.data)
def get_entity_entries(self) -> list[er.RegistryEntry]:
- """Get entity_ids of all configured entities by platform."""
+ """Get entity_ids of all UI configured entities."""
+ entity_registry = er.async_get(self.hass)
+ unique_ids = {
+ uid for platform in self.data["entities"].values() for uid in platform
+ }
return [
- entity.registry_entry
- for entity in self.entities.values()
- if entity.registry_entry is not None
+ registry_entry
+ for registry_entry in er.async_entries_for_config_entry(
+ entity_registry, self.config_entry.entry_id
+ )
+ if registry_entry.unique_id in unique_ids
]
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index d6e1e2f49f041d..8d8692f6b7aa01 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -267,6 +267,22 @@
}
},
"entity": {
+ "climate": {
+ "knx_climate": {
+ "state_attributes": {
+ "preset_mode": {
+ "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]",
+ "state": {
+ "auto": "Auto",
+ "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
+ "standby": "Standby",
+ "economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
+ "building_protection": "Building protection"
+ }
+ }
+ }
+ }
+ },
"sensor": {
"individual_address": {
"name": "[%key:component::knx::config::step::routing::data::individual_address%]"
diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py
index 0a8a1dff964401..ebe930957d6b28 100644
--- a/homeassistant/components/knx/switch.py
+++ b/homeassistant/components/knx/switch.py
@@ -4,7 +4,6 @@
from typing import Any
-from xknx import XKNX
from xknx.devices import Switch as XknxSwitch
from homeassistant import config_entries
@@ -18,9 +17,12 @@
STATE_UNKNOWN,
Platform,
)
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddEntitiesCallback,
+ async_get_current_platform,
+)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -33,7 +35,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
+from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import SwitchSchema
from .storage.const import (
CONF_DEVICE_INFO,
@@ -52,12 +54,21 @@ async def async_setup_entry(
) -> None:
"""Set up switch(es) for KNX platform."""
knx_module: KNXModule = hass.data[DOMAIN]
-
- entities: list[KnxEntity] = []
- if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH):
+ platform = async_get_current_platform()
+ knx_module.config_store.add_platform(
+ platform=Platform.SWITCH,
+ controller=KnxUiEntityPlatformController(
+ knx_module=knx_module,
+ entity_platform=platform,
+ entity_class=KnxUiSwitch,
+ ),
+ )
+
+ entities: list[KnxYamlEntity | KnxUiEntity] = []
+ if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH):
entities.extend(
- KnxYamlSwitch(knx_module.xknx, entity_config)
- for entity_config in yaml_config
+ KnxYamlSwitch(knx_module, entity_config)
+ for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH):
entities.extend(
@@ -67,15 +78,8 @@ async def async_setup_entry(
if entities:
async_add_entities(entities)
- @callback
- def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None:
- """Add KNX entity at runtime."""
- async_add_entities([KnxUiSwitch(knx_module, unique_id, config)])
-
- knx_module.config_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch
-
-class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity):
+class _KnxSwitch(SwitchEntity, RestoreEntity):
"""Base class for a KNX switch."""
_device: XknxSwitch
@@ -103,52 +107,53 @@ async def async_turn_off(self, **kwargs: Any) -> None:
await self._device.set_off()
-class KnxYamlSwitch(_KnxSwitch):
+class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
"""Representation of a KNX switch configured from YAML."""
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ _device: XknxSwitch
+
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX switch."""
super().__init__(
+ knx_module=knx_module,
device=XknxSwitch(
- xknx,
+ xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
- )
+ ),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_unique_id = str(self._device.switch.group_address)
-class KnxUiSwitch(_KnxSwitch):
+class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
"""Representation of a KNX switch configured from UI."""
_attr_has_entity_name = True
+ _device: XknxSwitch
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
- """Initialize of KNX switch."""
- super().__init__(
- device=XknxSwitch(
- knx_module.xknx,
- name=config[CONF_ENTITY][CONF_NAME],
- group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
- group_address_state=[
- config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
- *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
- ],
- respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
- sync_state=config[DOMAIN][CONF_SYNC_STATE],
- invert=config[DOMAIN][CONF_INVERT],
- )
+ """Initialize KNX switch."""
+ self._knx_module = knx_module
+ self._device = XknxSwitch(
+ knx_module.xknx,
+ name=config[CONF_ENTITY][CONF_NAME],
+ group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
+ group_address_state=[
+ config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
+ *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
+ ],
+ respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
+ sync_state=config[DOMAIN][CONF_SYNC_STATE],
+ invert=config[DOMAIN][CONF_INVERT],
)
self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY]
self._attr_unique_id = unique_id
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})
-
- knx_module.config_store.entities[unique_id] = self
diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py
index 82df78e748eb22..a96d841a07d8f5 100644
--- a/homeassistant/components/knx/telegrams.py
+++ b/homeassistant/components/knx/telegrams.py
@@ -7,6 +7,7 @@
from xknx import XKNX
from xknx.dpt import DPTArray, DPTBase, DPTBinary
+from xknx.dpt.dpt import DPTComplexData, DPTEnumData
from xknx.exceptions import XKNXException
from xknx.telegram import Telegram
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
@@ -34,7 +35,7 @@ class DecodedTelegramPayload(TypedDict):
dpt_sub: int | None
dpt_name: str | None
unit: str | None
- value: str | int | float | bool | None
+ value: bool | str | int | float | dict[str, str | int | float | bool] | None
class TelegramDict(DecodedTelegramPayload):
@@ -93,7 +94,7 @@ async def save_history(self) -> None:
if self.recent_telegrams:
await self._history_store.async_save(list(self.recent_telegrams))
- async def _xknx_telegram_cb(self, telegram: Telegram) -> None:
+ def _xknx_telegram_cb(self, telegram: Telegram) -> None:
"""Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
@@ -105,7 +106,7 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
payload_data: int | tuple[int, ...] | None = None
src_name = ""
transcoder = None
- decoded_payload: DecodedTelegramPayload | None = None
+ value = None
if (
ga_info := self.project.group_addresses.get(
@@ -113,7 +114,6 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
)
) is not None:
dst_name = ga_info.name
- transcoder = ga_info.transcoder
if (
device := self.project.devices.get(f"{telegram.source_address}")
@@ -122,41 +122,50 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)):
payload_data = telegram.payload.value.value
- if transcoder is not None:
- decoded_payload = decode_telegram_payload(
- payload=telegram.payload.value, transcoder=transcoder
- )
+
+ if telegram.decoded_data is not None:
+ transcoder = telegram.decoded_data.transcoder
+ value = _serializable_decoded_data(telegram.decoded_data.value)
return TelegramDict(
destination=f"{telegram.destination_address}",
destination_name=dst_name,
direction=telegram.direction.value,
- dpt_main=decoded_payload["dpt_main"]
- if decoded_payload is not None
- else None,
- dpt_sub=decoded_payload["dpt_sub"] if decoded_payload is not None else None,
- dpt_name=decoded_payload["dpt_name"]
- if decoded_payload is not None
- else None,
+ dpt_main=transcoder.dpt_main_number if transcoder is not None else None,
+ dpt_sub=transcoder.dpt_sub_number if transcoder is not None else None,
+ dpt_name=transcoder.value_type if transcoder is not None else None,
payload=payload_data,
source=f"{telegram.source_address}",
source_name=src_name,
telegramtype=telegram.payload.__class__.__name__,
timestamp=dt_util.now().isoformat(),
- unit=decoded_payload["unit"] if decoded_payload is not None else None,
- value=decoded_payload["value"] if decoded_payload is not None else None,
+ unit=transcoder.unit if transcoder is not None else None,
+ value=value,
)
+def _serializable_decoded_data(
+ value: bool | float | str | DPTComplexData | DPTEnumData,
+) -> bool | str | int | float | dict[str, str | int | float | bool]:
+ """Return a serializable representation of decoded data."""
+ if isinstance(value, DPTComplexData):
+ return value.as_dict()
+ if isinstance(value, DPTEnumData):
+ return value.name.lower()
+ return value
+
+
def decode_telegram_payload(
payload: DPTArray | DPTBinary, transcoder: type[DPTBase]
) -> DecodedTelegramPayload:
- """Decode the payload of a KNX telegram."""
+ """Decode the payload of a KNX telegram with custom transcoder."""
try:
value = transcoder.from_knx(payload)
except XKNXException:
value = "Error decoding value"
+ value = _serializable_decoded_data(value)
+
return DecodedTelegramPayload(
dpt_main=transcoder.dpt_main_number,
dpt_sub=transcoder.dpt_sub_number,
diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py
index 22d008cd5ceb81..381cb95ad32a8a 100644
--- a/homeassistant/components/knx/text.py
+++ b/homeassistant/components/knx/text.py
@@ -22,6 +22,7 @@
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
@@ -29,7 +30,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
async def async_setup_entry(
@@ -38,10 +39,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor(s) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TEXT]
- async_add_entities(KNXText(xknx, entity_config) for entity_config in config)
+ async_add_entities(KNXText(knx_module, entity_config) for entity_config in config)
def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
@@ -56,15 +57,18 @@ def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
)
-class KNXText(KnxEntity, TextEntity, RestoreEntity):
+class KNXText(KnxYamlEntity, TextEntity, RestoreEntity):
"""Representation of a KNX text."""
_device: XknxNotification
_attr_native_max = 14
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
- super().__init__(_create_notification(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_notification(knx_module.xknx, config),
+ )
self._attr_mode = config[CONF_MODE]
self._attr_pattern = (
r"[\u0000-\u00ff]*" # Latin-1
diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py
index c11b40d13dc864..b4e562a88699e2 100644
--- a/homeassistant/components/knx/time.py
+++ b/homeassistant/components/knx/time.py
@@ -3,11 +3,10 @@
from __future__ import annotations
from datetime import time as dt_time
-import time as time_time
-from typing import Final
from xknx import XKNX
-from xknx.devices import DateTime as XknxDateTime
+from xknx.devices import TimeDevice as XknxTimeDevice
+from xknx.dpt.dpt_10 import KNXTime as XknxTime
from homeassistant import config_entries
from homeassistant.components.time import TimeEntity
@@ -23,6 +22,7 @@
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
@@ -31,9 +31,7 @@
DOMAIN,
KNX_ADDRESS,
)
-from .knx_entity import KnxEntity
-
-_TIME_TRANSLATION_FORMAT: Final = "%H:%M:%S"
+from .knx_entity import KnxYamlEntity
async def async_setup_entry(
@@ -42,18 +40,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TIME]
- async_add_entities(KNXTime(xknx, entity_config) for entity_config in config)
+ async_add_entities(
+ KNXTimeEntity(knx_module, entity_config) for entity_config in config
+ )
-def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
+def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
"""Return a XKNX DateTime object to be used within XKNX."""
- return XknxDateTime(
+ return XknxTimeDevice(
xknx,
name=config[CONF_NAME],
- broadcast_type="TIME",
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
@@ -62,14 +61,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime:
)
-class KNXTime(KnxEntity, TimeEntity, RestoreEntity):
+class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
"""Representation of a KNX time."""
- _device: XknxDateTime
+ _device: XknxTimeDevice
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
- super().__init__(_create_xknx_device(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_xknx_device(knx_module.xknx, config),
+ )
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
@@ -81,25 +83,15 @@ async def async_added_to_hass(self) -> None:
and (last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
- self._device.remote_value.value = time_time.strptime(
- last_state.state, _TIME_TRANSLATION_FORMAT
+ self._device.remote_value.value = XknxTime.from_time(
+ dt_time.fromisoformat(last_state.state)
)
@property
def native_value(self) -> dt_time | None:
"""Return the latest value."""
- if (time_struct := self._device.remote_value.value) is None:
- return None
- return dt_time(
- hour=time_struct.tm_hour,
- minute=time_struct.tm_min,
- second=min(time_struct.tm_sec, 59), # account for leap seconds
- )
+ return self._device.value
async def async_set_value(self, value: dt_time) -> None:
"""Change the value."""
- time_struct = time_time.strptime(
- value.strftime(_TIME_TRANSLATION_FORMAT),
- _TIME_TRANSLATION_FORMAT,
- )
- await self._device.set(time_struct)
+ await self._device.set(value)
diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py
index 82149b21561808..ae3ba088357508 100644
--- a/homeassistant/components/knx/trigger.py
+++ b/homeassistant/components/knx/trigger.py
@@ -18,7 +18,7 @@
from .const import DOMAIN
from .schema import ga_validator
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload
-from .validation import sensor_type_validator
+from .validation import dpt_base_type_validator
TRIGGER_TELEGRAM: Final = "telegram"
@@ -44,7 +44,7 @@
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): PLATFORM_TYPE_TRIGGER_TELEGRAM,
- vol.Optional(CONF_TYPE, default=None): vol.Any(sensor_type_validator, None),
+ vol.Optional(CONF_TYPE, default=None): vol.Any(dpt_base_type_validator, None),
**TELEGRAM_TRIGGER_SCHEMA,
}
)
@@ -99,7 +99,7 @@ def async_call_trigger_action(
):
decoded_payload = decode_telegram_payload(
payload=telegram.payload.value, # type: ignore[union-attr] # checked via payload_apci
- transcoder=trigger_transcoder, # type: ignore[type-abstract] # parse_transcoder don't return abstract classes
+ transcoder=trigger_transcoder,
)
# overwrite decoded payload values in telegram_dict
telegram_trigger_data = {**trigger_data, **telegram_dict, **decoded_payload}
diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py
index 9ed4f32c92065a..0283b65f899bbb 100644
--- a/homeassistant/components/knx/validation.py
+++ b/homeassistant/components/knx/validation.py
@@ -1,6 +1,7 @@
"""Validation helpers for KNX config schemas."""
from collections.abc import Callable
+from enum import Enum
import ipaddress
from typing import Any
@@ -30,9 +31,10 @@ def dpt_value_validator(value: Any) -> str | int:
return dpt_value_validator
+dpt_base_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract]
numeric_type_validator = dpt_subclass_validator(DPTNumeric) # type: ignore[type-abstract]
-sensor_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract]
string_type_validator = dpt_subclass_validator(DPTString)
+sensor_type_validator = vol.Any(numeric_type_validator, string_type_validator)
def ga_validator(value: Any) -> str | int:
@@ -103,3 +105,36 @@ def ip_v4_validator(value: Any, multicast: bool | None = None) -> str:
cv.boolean,
cv.matches_regex(r"^(init|expire|every)( \d*)?$"),
)
+
+
+def backwards_compatible_xknx_climate_enum_member(enumClass: type[Enum]) -> vol.All:
+ """Transform a string to an enum member.
+
+ Backwards compatible with member names of xknx 2.x climate DPT Enums
+ due to unintentional breaking change in HA 2024.8.
+ """
+
+ def _string_transform(value: Any) -> str:
+ """Upper and slugify string and substitute old member names.
+
+ Previously this was checked against Enum values instead of names. These
+ looked like `FAN_ONLY = "Fan only"`, therefore the upper & replace part.
+ """
+ if not isinstance(value, str):
+ raise vol.Invalid("value should be a string")
+ name = value.upper().replace(" ", "_")
+ match name:
+ case "NIGHT":
+ return "ECONOMY"
+ case "FROST_PROTECTION":
+ return "BUILDING_PROTECTION"
+ case "DRY":
+ return "DEHUMIDIFICATION"
+ case _:
+ return name
+
+ return vol.All(
+ _string_transform,
+ vol.In(enumClass.__members__),
+ enumClass.__getitem__,
+ )
diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py
index 584c9fd332313e..99f4be962fe116 100644
--- a/homeassistant/components/knx/weather.py
+++ b/homeassistant/components/knx/weather.py
@@ -19,8 +19,9 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from . import KNXModule
from .const import DATA_KNX_CONFIG, DOMAIN
-from .knx_entity import KnxEntity
+from .knx_entity import KnxYamlEntity
from .schema import WeatherSchema
@@ -30,10 +31,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switch(es) for KNX platform."""
- xknx: XKNX = hass.data[DOMAIN].xknx
+ knx_module: KNXModule = hass.data[DOMAIN]
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.WEATHER]
- async_add_entities(KNXWeather(xknx, entity_config) for entity_config in config)
+ async_add_entities(
+ KNXWeather(knx_module, entity_config) for entity_config in config
+ )
def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather:
@@ -72,7 +75,7 @@ def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather:
)
-class KNXWeather(KnxEntity, WeatherEntity):
+class KNXWeather(KnxYamlEntity, WeatherEntity):
"""Representation of a KNX weather device."""
_device: XknxWeather
@@ -80,9 +83,12 @@ class KNXWeather(KnxEntity, WeatherEntity):
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
- def __init__(self, xknx: XKNX, config: ConfigType) -> None:
+ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
- super().__init__(_create_weather(xknx, config))
+ super().__init__(
+ knx_module=knx_module,
+ device=_create_weather(knx_module.xknx, config),
+ )
self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py
index 97758dc87c9d22..4af3012741a89d 100644
--- a/homeassistant/components/knx/websocket.py
+++ b/homeassistant/components/knx/websocket.py
@@ -154,6 +154,7 @@ async def ws_project_file_process(
knx: KNXModule = hass.data[DOMAIN]
try:
await knx.project.process_project_file(
+ xknx=knx.xknx,
file_id=msg["file_id"],
password=msg["password"],
)
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index 46dee891e3a801..cdbe4e334cbf41 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -529,10 +529,11 @@ def should_poll(self) -> bool:
return not self._connection.can_subscribe
@property
- def volume_level(self):
+ def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
if "volume" in self._app_properties:
return int(self._app_properties["volume"]) / 100.0
+ return None
@property
def is_volume_muted(self):
diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py
index 424a2d9164d59a..65b99d623f1bf5 100644
--- a/homeassistant/components/konnected/switch.py
+++ b/homeassistant/components/konnected/switch.py
@@ -102,13 +102,12 @@ async def async_turn_off(self, **kwargs: Any) -> None:
if resp.get(ATTR_STATE) is not None:
self._set_state(self._boolean_state(resp.get(ATTR_STATE)))
- def _boolean_state(self, int_state):
- if int_state is None:
- return False
+ def _boolean_state(self, int_state: int | None) -> bool | None:
if int_state == 0:
return self._activation == STATE_LOW
if int_state == 1:
return self._activation == STATE_HIGH
+ return None
def _set_state(self, state):
self._attr_is_on = state
diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json
index 1236f63ddad840..1cf8794237d751 100644
--- a/homeassistant/components/lacrosse_view/manifest.json
+++ b/homeassistant/components/lacrosse_view/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
"iot_class": "cloud_polling",
"loggers": ["lacrosse_view"],
- "requirements": ["lacrosse-view==1.0.1"]
+ "requirements": ["lacrosse-view==1.0.2"]
}
diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py
index 4e52e246d813a7..a2fc1320c2bd94 100644
--- a/homeassistant/components/landisgyr_heat_meter/__init__.py
+++ b/homeassistant/components/landisgyr_heat_meter/__init__.py
@@ -3,13 +3,14 @@
from __future__ import annotations
import logging
+from typing import Any
import ultraheat_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_registry import async_migrate_entries
+from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .const import DOMAIN
from .coordinator import UltraheatCoordinator
@@ -55,7 +56,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
device_number = config_entry.data["device_number"]
@callback
- def update_entity_unique_id(entity_entry):
+ def update_entity_unique_id(
+ entity_entry: RegistryEntry,
+ ) -> dict[str, Any] | None:
"""Update unique ID of entity entry."""
if entity_entry.platform in entity_entry.unique_id:
return {
@@ -64,6 +67,7 @@ def update_entity_unique_id(entity_entry):
f"{device_number}",
)
}
+ return None
await async_migrate_entries(
hass, config_entry.entry_id, update_entity_unique_id
diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py
index 27765d207d88d1..9eef6ad834367d 100644
--- a/homeassistant/components/lawn_mower/__init__.py
+++ b/homeassistant/components/lawn_mower/__init__.py
@@ -39,15 +39,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_START_MOWING,
- {},
+ None,
"async_start_mowing",
[LawnMowerEntityFeature.START_MOWING],
)
component.async_register_entity_service(
- SERVICE_PAUSE, {}, "async_pause", [LawnMowerEntityFeature.PAUSE]
+ SERVICE_PAUSE, None, "async_pause", [LawnMowerEntityFeature.PAUSE]
)
component.async_register_entity_service(
- SERVICE_DOCK, {}, "async_dock", [LawnMowerEntityFeature.DOCK]
+ SERVICE_DOCK, None, "async_dock", [LawnMowerEntityFeature.DOCK]
)
return True
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index 6153ecf45409f5..e9717774e17276 100644
--- a/homeassistant/components/lcn/manifest.json
+++ b/homeassistant/components/lcn/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
- "requirements": ["pypck==0.7.17"]
+ "requirements": ["pypck==0.7.21"]
}
diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json
index 2389e3199e2afa..a1b0e9a13987dc 100644
--- a/homeassistant/components/ld2410_ble/manifest.json
+++ b/homeassistant/components/ld2410_ble/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.19.3", "ld2410-ble==0.1.1"]
+ "requirements": ["bluetooth-data-tools==1.19.4", "ld2410-ble==0.1.1"]
}
diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json
index bf15ab1cc66e25..e22d23fb9712a4 100644
--- a/homeassistant/components/led_ble/manifest.json
+++ b/homeassistant/components/led_ble/manifest.json
@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
- "requirements": ["bluetooth-data-tools==1.19.3", "led-ble==1.0.2"]
+ "requirements": ["bluetooth-data-tools==1.19.4", "led-ble==1.0.2"]
}
diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json
index 0345d4ad727868..9ac2a9e66e6036 100644
--- a/homeassistant/components/linkplay/manifest.json
+++ b/homeassistant/components/linkplay/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/linkplay",
"integration_type": "hub",
"iot_class": "local_polling",
- "requirements": ["python-linkplay==0.0.5"],
+ "requirements": ["python-linkplay==0.0.6"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py
index 103b09f46da472..398add235bd789 100644
--- a/homeassistant/components/linkplay/media_player.py
+++ b/homeassistant/components/linkplay/media_player.py
@@ -58,6 +58,8 @@
LoopMode.CONTINUOUS_PLAYBACK: RepeatMode.ALL,
LoopMode.RANDOM_PLAYBACK: RepeatMode.ALL,
LoopMode.LIST_CYCLE: RepeatMode.ALL,
+ LoopMode.SHUFF_DISABLED_REPEAT_DISABLED: RepeatMode.OFF,
+ LoopMode.SHUFF_ENABLED_REPEAT_ENABLED_LOOP_ONCE: RepeatMode.ALL,
}
REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()}
diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py
index 9ca76b3933d95f..7532c9b354a774 100644
--- a/homeassistant/components/linkplay/utils.py
+++ b/homeassistant/components/linkplay/utils.py
@@ -3,9 +3,19 @@
from typing import Final
MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
+MANUFACTURER_ARYLIC: Final[str] = "Arylic"
+MANUFACTURER_IEAST: Final[str] = "iEAST"
MANUFACTURER_GENERIC: Final[str] = "Generic"
MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP"
MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde"
+MODELS_ARYLIC_S50: Final[str] = "S50+"
+MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro"
+MODELS_ARYLIC_A30: Final[str] = "A30"
+MODELS_ARYLIC_A50S: Final[str] = "A50+"
+MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
+MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
+MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
+MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
MODELS_GENERIC: Final[str] = "Generic"
@@ -16,5 +26,21 @@ def get_info_from_project(project: str) -> tuple[str, str]:
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4
case "SMART_HYDE":
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE
+ case "ARYLIC_S50":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50
+ case "RP0016_S50PRO_S":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO
+ case "RP0011_WB60_S":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30
+ case "ARYLIC_A50S":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S
+ case "UP2STREAM_AMP_V3":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3
+ case "UP2STREAM_AMP_V4":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4
+ case "UP2STREAM_PRO_V3":
+ return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3
+ case "iEAST-02":
+ return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
case _:
return MANUFACTURER_GENERIC, MODELS_GENERIC
diff --git a/homeassistant/components/local_ip/__init__.py b/homeassistant/components/local_ip/__init__.py
index 45ddbed7150446..72f5d4f7a433c9 100644
--- a/homeassistant/components/local_ip/__init__.py
+++ b/homeassistant/components/local_ip/__init__.py
@@ -2,11 +2,8 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
-from .const import DOMAIN, PLATFORMS
-
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
+from .const import PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py
index d520cafb80e9cc..239a52ff7a14d1 100644
--- a/homeassistant/components/logbook/__init__.py
+++ b/homeassistant/components/logbook/__init__.py
@@ -112,7 +112,6 @@ def log_message(service: ServiceCall) -> None:
# away so we use the "logbook" domain
domain = DOMAIN
- message.hass = hass
message = message.async_render(parse_result=False)
async_log_entry(hass, name, message, domain, entity_id, service.context)
diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py
index 2f9b2c8e289a73..8fd850b26fb392 100644
--- a/homeassistant/components/logbook/models.py
+++ b/homeassistant/components/logbook/models.py
@@ -5,7 +5,7 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from functools import cached_property
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast
from sqlalchemy.engine.row import Row
@@ -45,76 +45,96 @@ def __init__(
) -> None:
"""Init the lazy event."""
self.row = row
- self._event_data: dict[str, Any] | None = None
- self._event_data_cache = event_data_cache
# We need to explicitly check for the row is EventAsRow as the unhappy path
- # to fetch row.data for Row is very expensive
+ # to fetch row[DATA_POS] for Row is very expensive
if type(row) is EventAsRow:
# If its an EventAsRow we can avoid the whole
# json decode process as we already have the data
- self.data = row.data
+ self.data = row[DATA_POS]
return
if TYPE_CHECKING:
- source = cast(str, row.event_data)
+ source = cast(str, row[EVENT_DATA_POS])
else:
- source = row.event_data
+ source = row[EVENT_DATA_POS]
if not source:
self.data = {}
- elif event_data := self._event_data_cache.get(source):
+ elif event_data := event_data_cache.get(source):
self.data = event_data
else:
- self.data = self._event_data_cache[source] = cast(
+ self.data = event_data_cache[source] = cast(
dict[str, Any], json_loads(source)
)
@cached_property
def event_type(self) -> EventType[Any] | str | None:
"""Return the event type."""
- return self.row.event_type
+ return self.row[EVENT_TYPE_POS]
@cached_property
def entity_id(self) -> str | None:
"""Return the entity id."""
- return self.row.entity_id
+ return self.row[ENTITY_ID_POS]
@cached_property
def state(self) -> str | None:
"""Return the state."""
- return self.row.state
+ return self.row[STATE_POS]
@cached_property
def context_id(self) -> str | None:
"""Return the context id."""
- return bytes_to_ulid_or_none(self.row.context_id_bin)
+ return bytes_to_ulid_or_none(self.row[CONTEXT_ID_BIN_POS])
@cached_property
def context_user_id(self) -> str | None:
"""Return the context user id."""
- return bytes_to_uuid_hex_or_none(self.row.context_user_id_bin)
+ return bytes_to_uuid_hex_or_none(self.row[CONTEXT_USER_ID_BIN_POS])
@cached_property
def context_parent_id(self) -> str | None:
"""Return the context parent id."""
- return bytes_to_ulid_or_none(self.row.context_parent_id_bin)
+ return bytes_to_ulid_or_none(self.row[CONTEXT_PARENT_ID_BIN_POS])
-@dataclass(slots=True, frozen=True)
-class EventAsRow:
- """Convert an event to a row."""
+# Row order must match the query order in queries/common.py
+# ---------------------------------------------------------
+ROW_ID_POS: Final = 0
+EVENT_TYPE_POS: Final = 1
+EVENT_DATA_POS: Final = 2
+TIME_FIRED_TS_POS: Final = 3
+CONTEXT_ID_BIN_POS: Final = 4
+CONTEXT_USER_ID_BIN_POS: Final = 5
+CONTEXT_PARENT_ID_BIN_POS: Final = 6
+STATE_POS: Final = 7
+ENTITY_ID_POS: Final = 8
+ICON_POS: Final = 9
+CONTEXT_ONLY_POS: Final = 10
+# - For EventAsRow, additional fields are:
+DATA_POS: Final = 11
+CONTEXT_POS: Final = 12
+
+
+class EventAsRow(NamedTuple):
+ """Convert an event to a row.
+
+ This much always match the order of the columns in queries/common.py
+ """
+ row_id: int
+ event_type: EventType[Any] | str | None
+ event_data: str | None
+ time_fired_ts: float
+ context_id_bin: bytes
+ context_user_id_bin: bytes | None
+ context_parent_id_bin: bytes | None
+ state: str | None
+ entity_id: str | None
+ icon: str | None
+ context_only: bool | None
+
+ # Additional fields for EventAsRow
data: Mapping[str, Any]
context: Context
- context_id_bin: bytes
- time_fired_ts: float
- row_id: int
- event_data: str | None = None
- entity_id: str | None = None
- icon: str | None = None
- context_user_id_bin: bytes | None = None
- context_parent_id_bin: bytes | None = None
- event_type: EventType[Any] | str | None = None
- state: str | None = None
- context_only: None = None
@callback
@@ -123,14 +143,19 @@ def async_event_to_row(event: Event) -> EventAsRow:
if event.event_type != EVENT_STATE_CHANGED:
context = event.context
return EventAsRow(
- data=event.data,
- context=event.context,
+ row_id=hash(event),
event_type=event.event_type,
+ event_data=None,
+ time_fired_ts=event.time_fired_timestamp,
context_id_bin=ulid_to_bytes(context.id),
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
- time_fired_ts=event.time_fired_timestamp,
- row_id=hash(event),
+ state=None,
+ entity_id=None,
+ icon=None,
+ context_only=None,
+ data=event.data,
+ context=context,
)
# States are prefiltered so we never get states
# that are missing new_state or old_state
@@ -138,14 +163,17 @@ def async_event_to_row(event: Event) -> EventAsRow:
new_state: State = event.data["new_state"]
context = new_state.context
return EventAsRow(
- data=event.data,
- context=event.context,
- entity_id=new_state.entity_id,
- state=new_state.state,
+ row_id=hash(event),
+ event_type=None,
+ event_data=None,
+ time_fired_ts=new_state.last_updated_timestamp,
context_id_bin=ulid_to_bytes(context.id),
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
- time_fired_ts=new_state.last_updated_timestamp,
- row_id=hash(event),
+ state=new_state.state,
+ entity_id=new_state.entity_id,
icon=new_state.attributes.get(ATTR_ICON),
+ context_only=None,
+ data=event.data,
+ context=context,
)
diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py
index ed9888f83d09f2..77aa71740f11c1 100644
--- a/homeassistant/components/logbook/processor.py
+++ b/homeassistant/components/logbook/processor.py
@@ -3,11 +3,11 @@
from __future__ import annotations
from collections.abc import Callable, Generator, Sequence
-from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime as dt
import logging
-from typing import Any
+import time
+from typing import TYPE_CHECKING, Any
from sqlalchemy.engine import Result
from sqlalchemy.engine.row import Row
@@ -18,7 +18,6 @@
bytes_to_uuid_hex_or_none,
extract_event_type_ids,
extract_metadata_ids,
- process_datetime_to_timestamp,
process_timestamp_to_utc_isoformat,
)
from homeassistant.components.recorder.util import (
@@ -63,7 +62,23 @@
LOGBOOK_ENTRY_WHEN,
)
from .helpers import is_sensor_continuous
-from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_event_to_row
+from .models import (
+ CONTEXT_ID_BIN_POS,
+ CONTEXT_ONLY_POS,
+ CONTEXT_PARENT_ID_BIN_POS,
+ CONTEXT_POS,
+ CONTEXT_USER_ID_BIN_POS,
+ ENTITY_ID_POS,
+ EVENT_TYPE_POS,
+ ICON_POS,
+ ROW_ID_POS,
+ STATE_POS,
+ TIME_FIRED_TS_POS,
+ EventAsRow,
+ LazyEventPartialState,
+ LogbookConfig,
+ async_event_to_row,
+)
from .queries import statement_for_request
from .queries.common import PSEUDO_EVENT_STATE_CHANGED
@@ -82,7 +97,7 @@ class LogbookRun:
event_cache: EventCache
entity_name_cache: EntityNameCache
include_entity_name: bool
- format_time: Callable[[Row | EventAsRow], Any]
+ timestamp: bool
memoize_new_contexts: bool = True
@@ -111,16 +126,13 @@ def __init__(
self.context_id = context_id
logbook_config: LogbookConfig = hass.data[DOMAIN]
self.filters: Filters | None = logbook_config.sqlalchemy_filter
- format_time = (
- _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat
- )
self.logbook_run = LogbookRun(
context_lookup={None: None},
external_events=logbook_config.external_events,
event_cache=EventCache({}),
entity_name_cache=EntityNameCache(self.hass),
include_entity_name=include_entity_name,
- format_time=format_time,
+ timestamp=timestamp,
)
self.context_augmenter = ContextAugmenter(self.logbook_run)
@@ -199,26 +211,30 @@ def _humanify(
continuous_sensors: dict[str, bool] = {}
context_lookup = logbook_run.context_lookup
external_events = logbook_run.external_events
- event_cache = logbook_run.event_cache
- entity_name_cache = logbook_run.entity_name_cache
+ event_cache_get = logbook_run.event_cache.get
+ entity_name_cache_get = logbook_run.entity_name_cache.get
include_entity_name = logbook_run.include_entity_name
- format_time = logbook_run.format_time
+ timestamp = logbook_run.timestamp
memoize_new_contexts = logbook_run.memoize_new_contexts
+ get_context = context_augmenter.get_context
+ context_id_bin: bytes
+ data: dict[str, Any]
# Process rows
for row in rows:
- context_id_bin: bytes = row.context_id_bin
+ context_id_bin = row[CONTEXT_ID_BIN_POS]
if memoize_new_contexts and context_id_bin not in context_lookup:
context_lookup[context_id_bin] = row
- if row.context_only:
+ if row[CONTEXT_ONLY_POS]:
continue
- event_type = row.event_type
-
+ event_type = row[EVENT_TYPE_POS]
if event_type == EVENT_CALL_SERVICE:
continue
+
if event_type is PSEUDO_EVENT_STATE_CHANGED:
- entity_id = row.entity_id
- assert entity_id is not None
+ entity_id = row[ENTITY_ID_POS]
+ if TYPE_CHECKING:
+ assert entity_id is not None
# Skip continuous sensors
if (
is_continuous := continuous_sensors.get(entity_id)
@@ -229,50 +245,69 @@ def _humanify(
continue
data = {
- LOGBOOK_ENTRY_WHEN: format_time(row),
- LOGBOOK_ENTRY_STATE: row.state,
+ LOGBOOK_ENTRY_STATE: row[STATE_POS],
LOGBOOK_ENTRY_ENTITY_ID: entity_id,
}
if include_entity_name:
- data[LOGBOOK_ENTRY_NAME] = entity_name_cache.get(entity_id)
- if icon := row.icon:
+ data[LOGBOOK_ENTRY_NAME] = entity_name_cache_get(entity_id)
+ if icon := row[ICON_POS]:
data[LOGBOOK_ENTRY_ICON] = icon
- context_augmenter.augment(data, row, context_id_bin)
- yield data
-
elif event_type in external_events:
domain, describe_event = external_events[event_type]
try:
- data = describe_event(event_cache.get(row))
+ data = describe_event(event_cache_get(row))
except Exception:
_LOGGER.exception(
"Error with %s describe event for %s", domain, event_type
)
continue
- data[LOGBOOK_ENTRY_WHEN] = format_time(row)
data[LOGBOOK_ENTRY_DOMAIN] = domain
- context_augmenter.augment(data, row, context_id_bin)
- yield data
elif event_type == EVENT_LOGBOOK_ENTRY:
- event = event_cache.get(row)
+ event = event_cache_get(row)
if not (event_data := event.data):
continue
entry_domain = event_data.get(ATTR_DOMAIN)
entry_entity_id = event_data.get(ATTR_ENTITY_ID)
if entry_domain is None and entry_entity_id is not None:
- with suppress(IndexError):
- entry_domain = split_entity_id(str(entry_entity_id))[0]
+ entry_domain = split_entity_id(str(entry_entity_id))[0]
data = {
- LOGBOOK_ENTRY_WHEN: format_time(row),
LOGBOOK_ENTRY_NAME: event_data.get(ATTR_NAME),
LOGBOOK_ENTRY_MESSAGE: event_data.get(ATTR_MESSAGE),
LOGBOOK_ENTRY_DOMAIN: entry_domain,
LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id,
}
- context_augmenter.augment(data, row, context_id_bin)
- yield data
+
+ else:
+ continue
+
+ time_fired_ts = row[TIME_FIRED_TS_POS]
+ if timestamp:
+ when = time_fired_ts or time.time()
+ else:
+ when = process_timestamp_to_utc_isoformat(
+ dt_util.utc_from_timestamp(time_fired_ts) or dt_util.utcnow()
+ )
+ data[LOGBOOK_ENTRY_WHEN] = when
+
+ if context_user_id_bin := row[CONTEXT_USER_ID_BIN_POS]:
+ data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin)
+
+ # Augment context if its available but not if the context is the same as the row
+ # or if the context is the parent of the row
+ if (context_row := get_context(context_id_bin, row)) and not (
+ (row is context_row or _rows_ids_match(row, context_row))
+ and (
+ not (context_parent := row[CONTEXT_PARENT_ID_BIN_POS])
+ or not (context_row := get_context(context_parent, context_row))
+ or row is context_row
+ or _rows_ids_match(row, context_row)
+ )
+ ):
+ context_augmenter.augment(data, context_row)
+
+ yield data
class ContextAugmenter:
@@ -286,52 +321,28 @@ def __init__(self, logbook_run: LogbookRun) -> None:
self.event_cache = logbook_run.event_cache
self.include_entity_name = logbook_run.include_entity_name
- def _get_context_row(
- self, context_id_bin: bytes | None, row: Row | EventAsRow
+ def get_context(
+ self, context_id_bin: bytes | None, row: Row | EventAsRow | None
) -> Row | EventAsRow | None:
"""Get the context row from the id or row context."""
if context_id_bin is not None and (
context_row := self.context_lookup.get(context_id_bin)
):
return context_row
- if (context := getattr(row, "context", None)) is not None and (
- origin_event := context.origin_event
- ) is not None:
+ if (
+ type(row) is EventAsRow
+ and (context := row[CONTEXT_POS]) is not None
+ and (origin_event := context.origin_event) is not None
+ ):
return async_event_to_row(origin_event)
return None
- def augment(
- self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None
- ) -> None:
+ def augment(self, data: dict[str, Any], context_row: Row | EventAsRow) -> None:
"""Augment data from the row and cache."""
- if context_user_id_bin := row.context_user_id_bin:
- data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin)
-
- if not (context_row := self._get_context_row(context_id_bin, row)):
- return
-
- if _rows_match(row, context_row):
- # This is the first event with the given ID. Was it directly caused by
- # a parent event?
- context_parent_id_bin = row.context_parent_id_bin
- if (
- not context_parent_id_bin
- or (
- context_row := self._get_context_row(
- context_parent_id_bin, context_row
- )
- )
- is None
- ):
- return
- # Ensure the (parent) context_event exists and is not the root cause of
- # this log entry.
- if _rows_match(row, context_row):
- return
- event_type = context_row.event_type
+ event_type = context_row[EVENT_TYPE_POS]
# State change
- if context_entity_id := context_row.entity_id:
- data[CONTEXT_STATE] = context_row.state
+ if context_entity_id := context_row[ENTITY_ID_POS]:
+ data[CONTEXT_STATE] = context_row[STATE_POS]
data[CONTEXT_ENTITY_ID] = context_entity_id
if self.include_entity_name:
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(
@@ -374,23 +385,9 @@ def augment(
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(attr_entity_id)
-def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool:
+def _rows_ids_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool:
"""Check of rows match by using the same method as Events __hash__."""
- return bool(
- row is other_row or (row_id := row.row_id) and row_id == other_row.row_id
- )
-
-
-def _row_time_fired_isoformat(row: Row | EventAsRow) -> str:
- """Convert the row timed_fired to isoformat."""
- return process_timestamp_to_utc_isoformat(
- dt_util.utc_from_timestamp(row.time_fired_ts) or dt_util.utcnow()
- )
-
-
-def _row_time_fired_timestamp(row: Row | EventAsRow) -> float:
- """Convert the row timed_fired to timestamp."""
- return row.time_fired_ts or process_datetime_to_timestamp(dt_util.utcnow())
+ return bool((row_id := row[ROW_ID_POS]) and row_id == other_row[ROW_ID_POS])
class EntityNameCache:
diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py
index bd9efe7aba32d3..c7ba196275be6b 100644
--- a/homeassistant/components/logbook/rest_api.py
+++ b/homeassistant/components/logbook/rest_api.py
@@ -5,7 +5,7 @@
from collections.abc import Callable
from datetime import timedelta
from http import HTTPStatus
-from typing import Any, cast
+from typing import Any
from aiohttp import web
import voluptuous as vol
@@ -109,13 +109,6 @@ async def get(
def json_events() -> web.Response:
"""Fetch events and generate JSON."""
- return self.json(
- event_processor.get_events(
- start_day,
- end_day,
- )
- )
+ return self.json(event_processor.get_events(start_day, end_day))
- return cast(
- web.Response, await get_instance(hass).async_add_executor_job(json_events)
- )
+ return await get_instance(hass).async_add_executor_job(json_events)
diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py
index cac58971cde444..b776ad6303d77a 100644
--- a/homeassistant/components/logbook/websocket_api.py
+++ b/homeassistant/components/logbook/websocket_api.py
@@ -81,7 +81,6 @@ async def _async_send_historical_events(
msg_id: int,
start_time: dt,
end_time: dt,
- formatter: Callable[[int, Any], dict[str, Any]],
event_processor: EventProcessor,
partial: bool,
force_send: bool = False,
@@ -109,7 +108,6 @@ async def _async_send_historical_events(
msg_id,
start_time,
end_time,
- formatter,
event_processor,
partial,
)
@@ -131,7 +129,6 @@ async def _async_send_historical_events(
msg_id,
recent_query_start,
end_time,
- formatter,
event_processor,
partial=True,
)
@@ -143,7 +140,6 @@ async def _async_send_historical_events(
msg_id,
start_time,
recent_query_start,
- formatter,
event_processor,
partial,
)
@@ -164,7 +160,6 @@ async def _async_get_ws_stream_events(
msg_id: int,
start_time: dt,
end_time: dt,
- formatter: Callable[[int, Any], dict[str, Any]],
event_processor: EventProcessor,
partial: bool,
) -> tuple[bytes, dt | None]:
@@ -174,7 +169,6 @@ async def _async_get_ws_stream_events(
msg_id,
start_time,
end_time,
- formatter,
event_processor,
partial,
)
@@ -195,7 +189,6 @@ def _ws_stream_get_events(
msg_id: int,
start_day: dt,
end_day: dt,
- formatter: Callable[[int, Any], dict[str, Any]],
event_processor: EventProcessor,
partial: bool,
) -> tuple[bytes, dt | None]:
@@ -211,7 +204,7 @@ def _ws_stream_get_events(
# data in case the UI needs to show that historical
# data is still loading in the future
message["partial"] = True
- return json_bytes(formatter(msg_id, message)), last_time
+ return json_bytes(messages.event_message(msg_id, message)), last_time
async def _async_events_consumer(
@@ -318,7 +311,6 @@ async def ws_event_stream(
msg_id,
start_time,
end_time,
- messages.event_message,
event_processor,
partial=False,
)
@@ -385,7 +377,6 @@ def _queue_or_cancel(event: Event) -> None:
msg_id,
start_time,
subscriptions_setup_complete_time,
- messages.event_message,
event_processor,
partial=True,
# Force a send since the wait for the sync task
@@ -431,7 +422,6 @@ def _queue_or_cancel(event: Event) -> None:
# we could fetch the same event twice
(last_event_time or start_time) + timedelta(microseconds=1),
subscriptions_setup_complete_time,
- messages.event_message,
event_processor,
partial=False,
)
diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py
deleted file mode 100644
index 0713bcc438e5e3..00000000000000
--- a/homeassistant/components/logi_circle/__init__.py
+++ /dev/null
@@ -1,271 +0,0 @@
-"""Support for Logi Circle devices."""
-
-import asyncio
-
-from aiohttp.client_exceptions import ClientResponseError
-from logi_circle import LogiCircle
-from logi_circle.exception import AuthorizationFailed
-import voluptuous as vol
-
-from homeassistant import config_entries
-from homeassistant.components import persistent_notification
-from homeassistant.components.camera import ATTR_FILENAME
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_MODE,
- CONF_API_KEY,
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_MONITORED_CONDITIONS,
- CONF_SENSORS,
- EVENT_HOMEASSISTANT_STOP,
- Platform,
-)
-from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.typing import ConfigType
-
-from . import config_flow
-from .const import (
- CONF_REDIRECT_URI,
- DATA_LOGI,
- DEFAULT_CACHEDB,
- DOMAIN,
- LED_MODE_KEY,
- RECORDING_MODE_KEY,
- SIGNAL_LOGI_CIRCLE_RECONFIGURE,
- SIGNAL_LOGI_CIRCLE_RECORD,
- SIGNAL_LOGI_CIRCLE_SNAPSHOT,
-)
-from .sensor import SENSOR_TYPES
-
-NOTIFICATION_ID = "logi_circle_notification"
-NOTIFICATION_TITLE = "Logi Circle Setup"
-
-_TIMEOUT = 15 # seconds
-
-SERVICE_SET_CONFIG = "set_config"
-SERVICE_LIVESTREAM_SNAPSHOT = "livestream_snapshot"
-SERVICE_LIVESTREAM_RECORD = "livestream_record"
-
-ATTR_VALUE = "value"
-ATTR_DURATION = "duration"
-
-PLATFORMS = [Platform.CAMERA, Platform.SENSOR]
-
-SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES]
-
-SENSOR_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All(
- cv.ensure_list, [vol.In(SENSOR_KEYS)]
- )
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_CLIENT_ID): cv.string,
- vol.Required(CONF_CLIENT_SECRET): cv.string,
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_REDIRECT_URI): cv.string,
- vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-LOGI_CIRCLE_SERVICE_SET_CONFIG = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Required(ATTR_MODE): vol.In([LED_MODE_KEY, RECORDING_MODE_KEY]),
- vol.Required(ATTR_VALUE): cv.boolean,
- }
-)
-
-LOGI_CIRCLE_SERVICE_SNAPSHOT = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Required(ATTR_FILENAME): cv.template,
- }
-)
-
-LOGI_CIRCLE_SERVICE_RECORD = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Required(ATTR_FILENAME): cv.template,
- vol.Required(ATTR_DURATION): cv.positive_int,
- }
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up configured Logi Circle component."""
- if DOMAIN not in config:
- return True
-
- conf = config[DOMAIN]
-
- config_flow.register_flow_implementation(
- hass,
- DOMAIN,
- client_id=conf[CONF_CLIENT_ID],
- client_secret=conf[CONF_CLIENT_SECRET],
- api_key=conf[CONF_API_KEY],
- redirect_uri=conf[CONF_REDIRECT_URI],
- sensors=conf[CONF_SENSORS],
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
- )
- )
-
- return True
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up Logi Circle from a config entry."""
- ir.async_create_issue(
- hass,
- DOMAIN,
- DOMAIN,
- breaks_in_ha_version="2024.9.0",
- is_fixable=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="integration_removed",
- translation_placeholders={
- "entries": "/config/integrations/integration/logi_circle",
- },
- )
-
- logi_circle = LogiCircle(
- client_id=entry.data[CONF_CLIENT_ID],
- client_secret=entry.data[CONF_CLIENT_SECRET],
- api_key=entry.data[CONF_API_KEY],
- redirect_uri=entry.data[CONF_REDIRECT_URI],
- cache_file=hass.config.path(DEFAULT_CACHEDB),
- )
-
- if not logi_circle.authorized:
- persistent_notification.create(
- hass,
- (
- "Error: The cached access tokens are missing from"
- f" {DEFAULT_CACHEDB}.
Please unload then re-add the Logi Circle"
- " integration to resolve."
- ),
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
-
- try:
- async with asyncio.timeout(_TIMEOUT):
- # Ensure the cameras property returns the same Camera objects for
- # all devices. Performs implicit login and session validation.
- await logi_circle.synchronize_cameras()
- except AuthorizationFailed:
- persistent_notification.create(
- hass,
- (
- "Error: Failed to obtain an access token from the cached "
- "refresh token.
"
- "Token may have expired or been revoked.
"
- "Please unload then re-add the Logi Circle integration to resolve"
- ),
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
- except TimeoutError:
- # The TimeoutError exception object returns nothing when casted to a
- # string, so we'll handle it separately.
- err = f"{_TIMEOUT}s timeout exceeded when connecting to Logi Circle API"
- persistent_notification.create(
- hass,
- f"Error: {err}
You will need to restart hass after fixing.",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
- except ClientResponseError as ex:
- persistent_notification.create(
- hass,
- f"Error: {ex}
You will need to restart hass after fixing.",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
-
- hass.data[DATA_LOGI] = logi_circle
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
- async def service_handler(service: ServiceCall) -> None:
- """Dispatch service calls to target entities."""
- params = dict(service.data)
-
- if service.service == SERVICE_SET_CONFIG:
- async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECONFIGURE, params)
- if service.service == SERVICE_LIVESTREAM_SNAPSHOT:
- async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_SNAPSHOT, params)
- if service.service == SERVICE_LIVESTREAM_RECORD:
- async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECORD, params)
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_CONFIG,
- service_handler,
- schema=LOGI_CIRCLE_SERVICE_SET_CONFIG,
- )
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_LIVESTREAM_SNAPSHOT,
- service_handler,
- schema=LOGI_CIRCLE_SERVICE_SNAPSHOT,
- )
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_LIVESTREAM_RECORD,
- service_handler,
- schema=LOGI_CIRCLE_SERVICE_RECORD,
- )
-
- async def shut_down(event=None):
- """Close Logi Circle aiohttp session."""
- await logi_circle.auth_provider.close()
-
- entry.async_on_unload(
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down)
- )
-
- return True
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload a config entry."""
- if all(
- config_entry.state is config_entries.ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
- logi_circle = hass.data.pop(DATA_LOGI)
-
- # Tell API wrapper to close all aiohttp sessions, invalidate WS connections
- # and clear all locally cached tokens
- await logi_circle.auth_provider.clear_authorization()
-
- return unload_ok
diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py
deleted file mode 100644
index ad31713d734ca4..00000000000000
--- a/homeassistant/components/logi_circle/camera.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""Support to the Logi Circle cameras."""
-
-from __future__ import annotations
-
-from datetime import timedelta
-import logging
-
-from homeassistant.components.camera import Camera, CameraEntityFeature
-from homeassistant.components.ffmpeg import get_ffmpeg_manager
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_BATTERY_CHARGING,
- ATTR_BATTERY_LEVEL,
- ATTR_ENTITY_ID,
- STATE_OFF,
- STATE_ON,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-
-from .const import (
- ATTRIBUTION,
- DEVICE_BRAND,
- DOMAIN as LOGI_CIRCLE_DOMAIN,
- LED_MODE_KEY,
- RECORDING_MODE_KEY,
- SIGNAL_LOGI_CIRCLE_RECONFIGURE,
- SIGNAL_LOGI_CIRCLE_RECORD,
- SIGNAL_LOGI_CIRCLE_SNAPSHOT,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-SCAN_INTERVAL = timedelta(seconds=60)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a Logi Circle Camera. Obsolete."""
- _LOGGER.warning("Logi Circle no longer works with camera platform configuration")
-
-
-async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
-) -> None:
- """Set up a Logi Circle Camera based on a config entry."""
- devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
- ffmpeg = get_ffmpeg_manager(hass)
-
- cameras = [LogiCam(device, ffmpeg) for device in devices]
-
- async_add_entities(cameras, True)
-
-
-class LogiCam(Camera):
- """An implementation of a Logi Circle camera."""
-
- _attr_attribution = ATTRIBUTION
- _attr_should_poll = True # Cameras default to False
- _attr_supported_features = CameraEntityFeature.ON_OFF
- _attr_has_entity_name = True
- _attr_name = None
-
- def __init__(self, camera, ffmpeg):
- """Initialize Logi Circle camera."""
- super().__init__()
- self._camera = camera
- self._has_battery = camera.supports_feature("battery_level")
- self._ffmpeg = ffmpeg
- self._listeners = []
- self._attr_unique_id = camera.mac_address
- self._attr_device_info = DeviceInfo(
- identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)},
- manufacturer=DEVICE_BRAND,
- model=camera.model_name,
- name=camera.name,
- sw_version=camera.firmware,
- )
-
- async def async_added_to_hass(self) -> None:
- """Connect camera methods to signals."""
-
- def _dispatch_proxy(method):
- """Expand parameters & filter entity IDs."""
-
- async def _call(params):
- entity_ids = params.get(ATTR_ENTITY_ID)
- filtered_params = {
- k: v for k, v in params.items() if k != ATTR_ENTITY_ID
- }
- if entity_ids is None or self.entity_id in entity_ids:
- await method(**filtered_params)
-
- return _call
-
- self._listeners.extend(
- [
- async_dispatcher_connect(
- self.hass,
- SIGNAL_LOGI_CIRCLE_RECONFIGURE,
- _dispatch_proxy(self.set_config),
- ),
- async_dispatcher_connect(
- self.hass,
- SIGNAL_LOGI_CIRCLE_SNAPSHOT,
- _dispatch_proxy(self.livestream_snapshot),
- ),
- async_dispatcher_connect(
- self.hass,
- SIGNAL_LOGI_CIRCLE_RECORD,
- _dispatch_proxy(self.download_livestream),
- ),
- ]
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect dispatcher listeners when removed."""
- for detach in self._listeners:
- detach()
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- state = {
- "battery_saving_mode": (
- STATE_ON if self._camera.battery_saving else STATE_OFF
- ),
- "microphone_gain": self._camera.microphone_gain,
- }
-
- # Add battery attributes if camera is battery-powered
- if self._has_battery:
- state[ATTR_BATTERY_CHARGING] = self._camera.charging
- state[ATTR_BATTERY_LEVEL] = self._camera.battery_level
-
- return state
-
- async def async_camera_image(
- self, width: int | None = None, height: int | None = None
- ) -> bytes | None:
- """Return a still image from the camera."""
- return await self._camera.live_stream.download_jpeg()
-
- async def async_turn_off(self) -> None:
- """Disable streaming mode for this camera."""
- await self._camera.set_config("streaming", False)
-
- async def async_turn_on(self) -> None:
- """Enable streaming mode for this camera."""
- await self._camera.set_config("streaming", True)
-
- async def set_config(self, mode, value):
- """Set an configuration property for the target camera."""
- if mode == LED_MODE_KEY:
- await self._camera.set_config("led", value)
- if mode == RECORDING_MODE_KEY:
- await self._camera.set_config("recording_disabled", not value)
-
- async def download_livestream(self, filename, duration):
- """Download a recording from the camera's livestream."""
- # Render filename from template.
- filename.hass = self.hass
- stream_file = filename.async_render(variables={ATTR_ENTITY_ID: self.entity_id})
-
- # Respect configured allowed paths.
- if not self.hass.config.is_allowed_path(stream_file):
- _LOGGER.error("Can't write %s, no access to path!", stream_file)
- return
-
- await self._camera.live_stream.download_rtsp(
- filename=stream_file,
- duration=timedelta(seconds=duration),
- ffmpeg_bin=self._ffmpeg.binary,
- )
-
- async def livestream_snapshot(self, filename):
- """Download a still frame from the camera's livestream."""
- # Render filename from template.
- filename.hass = self.hass
- snapshot_file = filename.async_render(
- variables={ATTR_ENTITY_ID: self.entity_id}
- )
-
- # Respect configured allowed paths.
- if not self.hass.config.is_allowed_path(snapshot_file):
- _LOGGER.error("Can't write %s, no access to path!", snapshot_file)
- return
-
- await self._camera.live_stream.download_jpeg(
- filename=snapshot_file, refresh=True
- )
-
- async def async_update(self) -> None:
- """Update camera entity and refresh attributes."""
- await self._camera.update()
diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py
deleted file mode 100644
index 6c1a549aa04901..00000000000000
--- a/homeassistant/components/logi_circle/config_flow.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""Config flow to configure Logi Circle component."""
-
-import asyncio
-from collections import OrderedDict
-from http import HTTPStatus
-
-from logi_circle import LogiCircle
-from logi_circle.exception import AuthorizationFailed
-import voluptuous as vol
-
-from homeassistant.components.http import KEY_HASS, HomeAssistantView
-from homeassistant.config_entries import ConfigFlow
-from homeassistant.const import (
- CONF_API_KEY,
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_SENSORS,
-)
-from homeassistant.core import callback
-
-from .const import CONF_REDIRECT_URI, DEFAULT_CACHEDB, DOMAIN
-
-_TIMEOUT = 15 # seconds
-
-DATA_FLOW_IMPL = "logi_circle_flow_implementation"
-EXTERNAL_ERRORS = "logi_errors"
-AUTH_CALLBACK_PATH = "/api/logi_circle"
-AUTH_CALLBACK_NAME = "api:logi_circle"
-
-
-@callback
-def register_flow_implementation(
- hass, domain, client_id, client_secret, api_key, redirect_uri, sensors
-):
- """Register a flow implementation.
-
- domain: Domain of the component responsible for the implementation.
- client_id: Client ID.
- client_secret: Client secret.
- api_key: API key issued by Logitech.
- redirect_uri: Auth callback redirect URI.
- sensors: Sensor config.
- """
- if DATA_FLOW_IMPL not in hass.data:
- hass.data[DATA_FLOW_IMPL] = OrderedDict()
-
- hass.data[DATA_FLOW_IMPL][domain] = {
- CONF_CLIENT_ID: client_id,
- CONF_CLIENT_SECRET: client_secret,
- CONF_API_KEY: api_key,
- CONF_REDIRECT_URI: redirect_uri,
- CONF_SENSORS: sensors,
- EXTERNAL_ERRORS: None,
- }
-
-
-class LogiCircleFlowHandler(ConfigFlow, domain=DOMAIN):
- """Config flow for Logi Circle component."""
-
- VERSION = 1
-
- def __init__(self) -> None:
- """Initialize flow."""
- self.flow_impl = None
-
- async def async_step_import(self, user_input=None):
- """Handle external yaml configuration."""
- self._async_abort_entries_match()
-
- self.flow_impl = DOMAIN
-
- return await self.async_step_auth()
-
- async def async_step_user(self, user_input=None):
- """Handle a flow start."""
- flows = self.hass.data.get(DATA_FLOW_IMPL, {})
-
- self._async_abort_entries_match()
-
- if not flows:
- return self.async_abort(reason="missing_configuration")
-
- if len(flows) == 1:
- self.flow_impl = list(flows)[0]
- return await self.async_step_auth()
-
- if user_input is not None:
- self.flow_impl = user_input["flow_impl"]
- return await self.async_step_auth()
-
- return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}),
- )
-
- async def async_step_auth(self, user_input=None):
- """Create an entry for auth."""
- if self._async_current_entries():
- return self.async_abort(reason="external_setup")
-
- external_error = self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]
- errors = {}
- if external_error:
- # Handle error from another flow
- errors["base"] = external_error
- self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS] = None
- elif user_input is not None:
- errors["base"] = "follow_link"
-
- url = self._get_authorization_url()
-
- return self.async_show_form(
- step_id="auth",
- description_placeholders={"authorization_url": url},
- errors=errors,
- )
-
- def _get_authorization_url(self):
- """Create temporary Circle session and generate authorization url."""
- flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
- client_id = flow[CONF_CLIENT_ID]
- client_secret = flow[CONF_CLIENT_SECRET]
- api_key = flow[CONF_API_KEY]
- redirect_uri = flow[CONF_REDIRECT_URI]
-
- logi_session = LogiCircle(
- client_id=client_id,
- client_secret=client_secret,
- api_key=api_key,
- redirect_uri=redirect_uri,
- )
-
- self.hass.http.register_view(LogiCircleAuthCallbackView())
-
- return logi_session.authorize_url
-
- async def async_step_code(self, code=None):
- """Received code for authentication."""
- self._async_abort_entries_match()
-
- return await self._async_create_session(code)
-
- async def _async_create_session(self, code):
- """Create Logi Circle session and entries."""
- flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN]
- client_id = flow[CONF_CLIENT_ID]
- client_secret = flow[CONF_CLIENT_SECRET]
- api_key = flow[CONF_API_KEY]
- redirect_uri = flow[CONF_REDIRECT_URI]
- sensors = flow[CONF_SENSORS]
-
- logi_session = LogiCircle(
- client_id=client_id,
- client_secret=client_secret,
- api_key=api_key,
- redirect_uri=redirect_uri,
- cache_file=self.hass.config.path(DEFAULT_CACHEDB),
- )
-
- try:
- async with asyncio.timeout(_TIMEOUT):
- await logi_session.authorize(code)
- except AuthorizationFailed:
- (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth"
- return self.async_abort(reason="external_error")
- except TimeoutError:
- (
- self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]
- ) = "authorize_url_timeout"
- return self.async_abort(reason="external_error")
-
- account_id = (await logi_session.account)["accountId"]
- await logi_session.close()
- return self.async_create_entry(
- title=f"Logi Circle ({account_id})",
- data={
- CONF_CLIENT_ID: client_id,
- CONF_CLIENT_SECRET: client_secret,
- CONF_API_KEY: api_key,
- CONF_REDIRECT_URI: redirect_uri,
- CONF_SENSORS: sensors,
- },
- )
-
-
-class LogiCircleAuthCallbackView(HomeAssistantView):
- """Logi Circle Authorization Callback View."""
-
- requires_auth = False
- url = AUTH_CALLBACK_PATH
- name = AUTH_CALLBACK_NAME
-
- async def get(self, request):
- """Receive authorization code."""
- hass = request.app[KEY_HASS]
- if "code" in request.query:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "code"}, data=request.query["code"]
- )
- )
- return self.json_message("Authorisation code saved")
- return self.json_message(
- "Authorisation code missing from query string",
- status_code=HTTPStatus.BAD_REQUEST,
- )
diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py
deleted file mode 100644
index e144f47ce4eb56..00000000000000
--- a/homeassistant/components/logi_circle/const.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Constants in Logi Circle component."""
-
-from __future__ import annotations
-
-DOMAIN = "logi_circle"
-DATA_LOGI = DOMAIN
-
-CONF_REDIRECT_URI = "redirect_uri"
-
-DEFAULT_CACHEDB = ".logi_cache.pickle"
-
-
-LED_MODE_KEY = "LED"
-RECORDING_MODE_KEY = "RECORDING_MODE"
-
-SIGNAL_LOGI_CIRCLE_RECONFIGURE = "logi_circle_reconfigure"
-SIGNAL_LOGI_CIRCLE_SNAPSHOT = "logi_circle_snapshot"
-SIGNAL_LOGI_CIRCLE_RECORD = "logi_circle_record"
-
-# Attribution
-ATTRIBUTION = "Data provided by circle.logi.com"
-DEVICE_BRAND = "Logitech"
diff --git a/homeassistant/components/logi_circle/icons.json b/homeassistant/components/logi_circle/icons.json
deleted file mode 100644
index 9289746d3750b9..00000000000000
--- a/homeassistant/components/logi_circle/icons.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "services": {
- "set_config": "mdi:cog",
- "livestream_snapshot": "mdi:camera",
- "livestream_record": "mdi:record-rec"
- }
-}
diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json
deleted file mode 100644
index f4f65b2250571c..00000000000000
--- a/homeassistant/components/logi_circle/manifest.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "domain": "logi_circle",
- "name": "Logi Circle",
- "codeowners": ["@evanjd"],
- "config_flow": true,
- "dependencies": ["ffmpeg", "http"],
- "documentation": "https://www.home-assistant.io/integrations/logi_circle",
- "iot_class": "cloud_polling",
- "loggers": ["logi_circle"],
- "requirements": ["logi-circle==0.2.3"]
-}
diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py
deleted file mode 100644
index 121cb8848ae341..00000000000000
--- a/homeassistant/components/logi_circle/sensor.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Support for Logi Circle sensors."""
-
-from __future__ import annotations
-
-import logging
-from typing import Any
-
-from homeassistant.components.sensor import (
- SensorDeviceClass,
- SensorEntity,
- SensorEntityDescription,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_BATTERY_CHARGING,
- CONF_MONITORED_CONDITIONS,
- CONF_SENSORS,
- PERCENTAGE,
- STATE_OFF,
- STATE_ON,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util.dt import as_local
-
-from .const import ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
-
-
-SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
- SensorEntityDescription(
- key="battery_level",
- native_unit_of_measurement=PERCENTAGE,
- device_class=SensorDeviceClass.BATTERY,
- ),
- SensorEntityDescription(
- key="last_activity_time",
- translation_key="last_activity",
- icon="mdi:history",
- ),
- SensorEntityDescription(
- key="recording",
- translation_key="recording_mode",
- icon="mdi:eye",
- ),
- SensorEntityDescription(
- key="signal_strength_category",
- translation_key="wifi_signal_category",
- icon="mdi:wifi",
- ),
- SensorEntityDescription(
- key="signal_strength_percentage",
- translation_key="wifi_signal_strength",
- native_unit_of_measurement=PERCENTAGE,
- icon="mdi:wifi",
- ),
- SensorEntityDescription(
- key="streaming",
- translation_key="streaming_mode",
- icon="mdi:camera",
- ),
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Set up a sensor for a Logi Circle device. Obsolete."""
- _LOGGER.warning("Logi Circle no longer works with sensor platform configuration")
-
-
-async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
-) -> None:
- """Set up a Logi Circle sensor based on a config entry."""
- devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
- time_zone = str(hass.config.time_zone)
-
- monitored_conditions = entry.data[CONF_SENSORS].get(CONF_MONITORED_CONDITIONS)
- entities = [
- LogiSensor(device, time_zone, description)
- for description in SENSOR_TYPES
- if description.key in monitored_conditions
- for device in devices
- if device.supports_feature(description.key)
- ]
-
- async_add_entities(entities, True)
-
-
-class LogiSensor(SensorEntity):
- """A sensor implementation for a Logi Circle camera."""
-
- _attr_attribution = ATTRIBUTION
- _attr_has_entity_name = True
-
- def __init__(self, camera, time_zone, description: SensorEntityDescription) -> None:
- """Initialize a sensor for Logi Circle camera."""
- self.entity_description = description
- self._camera = camera
- self._attr_unique_id = f"{camera.mac_address}-{description.key}"
- self._activity: dict[Any, Any] = {}
- self._tz = time_zone
- self._attr_device_info = DeviceInfo(
- identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)},
- manufacturer=DEVICE_BRAND,
- model=camera.model_name,
- name=camera.name,
- sw_version=camera.firmware,
- )
-
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- state = {
- "battery_saving_mode": (
- STATE_ON if self._camera.battery_saving else STATE_OFF
- ),
- "microphone_gain": self._camera.microphone_gain,
- }
-
- if self.entity_description.key == "battery_level":
- state[ATTR_BATTERY_CHARGING] = self._camera.charging
-
- return state
-
- @property
- def icon(self):
- """Icon to use in the frontend, if any."""
- sensor_type = self.entity_description.key
- if sensor_type == "recording_mode" and self._attr_native_value is not None:
- return "mdi:eye" if self._attr_native_value == STATE_ON else "mdi:eye-off"
- if sensor_type == "streaming_mode" and self._attr_native_value is not None:
- return (
- "mdi:camera"
- if self._attr_native_value == STATE_ON
- else "mdi:camera-off"
- )
- return self.entity_description.icon
-
- async def async_update(self) -> None:
- """Get the latest data and updates the state."""
- _LOGGER.debug("Pulling data from %s sensor", self.name)
- await self._camera.update()
-
- if self.entity_description.key == "last_activity_time":
- last_activity = await self._camera.get_last_activity(force_refresh=True)
- if last_activity is not None:
- last_activity_time = as_local(last_activity.end_time_utc)
- self._attr_native_value = (
- f"{last_activity_time.hour:0>2}:{last_activity_time.minute:0>2}"
- )
- else:
- state = getattr(self._camera, self.entity_description.key, None)
- if isinstance(state, bool):
- self._attr_native_value = STATE_ON if state is True else STATE_OFF
- else:
- self._attr_native_value = state
diff --git a/homeassistant/components/logi_circle/services.yaml b/homeassistant/components/logi_circle/services.yaml
deleted file mode 100644
index cb855a953a6901..00000000000000
--- a/homeassistant/components/logi_circle/services.yaml
+++ /dev/null
@@ -1,53 +0,0 @@
-# Describes the format for available Logi Circle services
-
-set_config:
- fields:
- entity_id:
- selector:
- entity:
- integration: logi_circle
- domain: camera
- mode:
- required: true
- selector:
- select:
- options:
- - "LED"
- - "RECORDING_MODE"
- value:
- required: true
- selector:
- boolean:
-
-livestream_snapshot:
- fields:
- entity_id:
- selector:
- entity:
- integration: logi_circle
- domain: camera
- filename:
- required: true
- example: "/tmp/snapshot_{{ entity_id }}.jpg"
- selector:
- text:
-
-livestream_record:
- fields:
- entity_id:
- selector:
- entity:
- integration: logi_circle
- domain: camera
- filename:
- required: true
- example: "/tmp/snapshot_{{ entity_id }}.mp4"
- selector:
- text:
- duration:
- required: true
- selector:
- number:
- min: 1
- max: 3600
- unit_of_measurement: seconds
diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json
deleted file mode 100644
index be0f4632c25aae..00000000000000
--- a/homeassistant/components/logi_circle/strings.json
+++ /dev/null
@@ -1,105 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "Authentication Provider",
- "description": "Pick via which authentication provider you want to authenticate with Logi Circle.",
- "data": {
- "flow_impl": "Provider"
- }
- },
- "auth": {
- "title": "Authenticate with Logi Circle",
- "description": "Please follow the link below and **Accept** access to your Logi Circle account, then come back and press **Submit** below.\n\n[Link]({authorization_url})"
- }
- },
- "error": {
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
- "follow_link": "Please follow the link and authenticate before pressing Submit."
- },
- "abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
- "external_error": "Exception occurred from another flow.",
- "external_setup": "Logi Circle successfully configured from another flow.",
- "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]"
- }
- },
- "entity": {
- "sensor": {
- "last_activity": {
- "name": "Last activity"
- },
- "recording_mode": {
- "name": "Recording mode"
- },
- "wifi_signal_category": {
- "name": "Wi-Fi signal category"
- },
- "wifi_signal_strength": {
- "name": "Wi-Fi signal strength"
- },
- "streaming_mode": {
- "name": "Streaming mode"
- }
- }
- },
- "issues": {
- "integration_removed": {
- "title": "The Logi Circle integration has been deprecated and will be removed",
- "description": "Logitech stopped accepting applications for access to the Logi Circle API in May 2022, and the Logi Circle integration will be removed from Home Assistant.\n\nTo resolve this issue, please remove the integration entries from your Home Assistant setup. [Click here to see your existing Logi Circle integration entries]({entries})."
- }
- },
- "services": {
- "set_config": {
- "name": "Set config",
- "description": "Sets a configuration property.",
- "fields": {
- "entity_id": {
- "name": "Entity",
- "description": "Name(s) of entities to apply the operation mode to."
- },
- "mode": {
- "name": "[%key:common::config_flow::data::mode%]",
- "description": "Operation mode. Allowed values: LED, RECORDING_MODE."
- },
- "value": {
- "name": "Value",
- "description": "Operation value."
- }
- }
- },
- "livestream_snapshot": {
- "name": "Livestream snapshot",
- "description": "Takes a snapshot from the camera's livestream. Will wake the camera from sleep if required.",
- "fields": {
- "entity_id": {
- "name": "Entity",
- "description": "Name(s) of entities to create snapshots from."
- },
- "filename": {
- "name": "File name",
- "description": "Template of a Filename. Variable is entity_id."
- }
- }
- },
- "livestream_record": {
- "name": "Livestream record",
- "description": "Takes a video recording from the camera's livestream.",
- "fields": {
- "entity_id": {
- "name": "Entity",
- "description": "Name(s) of entities to create recordings from."
- },
- "filename": {
- "name": "File name",
- "description": "[%key:component::logi_circle::services::livestream_snapshot::fields::filename::description%]"
- },
- "duration": {
- "name": "Duration",
- "description": "Recording duration."
- }
- }
- }
- }
-}
diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py
index 2ef7864566f368..9079b0567311e2 100644
--- a/homeassistant/components/luftdaten/__init__.py
+++ b/homeassistant/components/luftdaten/__init__.py
@@ -15,7 +15,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
@@ -24,8 +23,6 @@
PLATFORMS = [Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sensor.Community as config entry."""
diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py
index 51bba44aef0768..c059367497290b 100644
--- a/homeassistant/components/lupusec/__init__.py
+++ b/homeassistant/components/lupusec/__init__.py
@@ -5,24 +5,10 @@
import lupupy
from lupupy.exceptions import LupusecException
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- CONF_HOST,
- CONF_IP_ADDRESS,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_USERNAME,
- Platform,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
-
-from .const import INTEGRATION_TITLE, ISSUE_PLACEHOLDER
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
@@ -31,19 +17,6 @@
NOTIFICATION_ID = "lupusec_notification"
NOTIFICATION_TITLE = "Lupusec Security Setup"
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_IP_ADDRESS): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
@@ -52,56 +25,6 @@
]
-async def handle_async_init_result(hass: HomeAssistant, domain: str, conf: dict):
- """Handle the result of the async_init to issue deprecated warnings."""
- flow = hass.config_entries.flow
- result = await flow.async_init(domain, context={"source": SOURCE_IMPORT}, data=conf)
-
- if (
- result["type"] == FlowResultType.CREATE_ENTRY
- or result["reason"] == "already_configured"
- ):
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": INTEGRATION_TITLE,
- },
- )
- else:
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{result['reason']}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
- translation_placeholders=ISSUE_PLACEHOLDER,
- )
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the lupusec integration."""
-
- if DOMAIN not in config:
- return True
-
- conf = config[DOMAIN]
-
- hass.async_create_task(handle_async_init_result(hass, DOMAIN, conf))
-
- return True
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py
index 82162bccf80a10..45b2b2b0cd868a 100644
--- a/homeassistant/components/lupusec/config_flow.py
+++ b/homeassistant/components/lupusec/config_flow.py
@@ -8,13 +8,7 @@
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_HOST,
- CONF_IP_ADDRESS,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_USERNAME,
-)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -66,37 +60,6 @@ async def async_step_user(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
- async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
- """Import the yaml config."""
- self._async_abort_entries_match(
- {
- CONF_HOST: user_input[CONF_IP_ADDRESS],
- CONF_USERNAME: user_input[CONF_USERNAME],
- CONF_PASSWORD: user_input[CONF_PASSWORD],
- }
- )
- host = user_input[CONF_IP_ADDRESS]
- username = user_input[CONF_USERNAME]
- password = user_input[CONF_PASSWORD]
- try:
- await test_host_connection(self.hass, host, username, password)
- except CannotConnect:
- return self.async_abort(reason="cannot_connect")
- except JSONDecodeError:
- return self.async_abort(reason="cannot_connect")
- except Exception:
- _LOGGER.exception("Unexpected exception")
- return self.async_abort(reason="unknown")
-
- return self.async_create_entry(
- title=user_input.get(CONF_NAME, host),
- data={
- CONF_HOST: host,
- CONF_USERNAME: username,
- CONF_PASSWORD: password,
- },
- )
-
async def test_host_connection(
hass: HomeAssistant, host: str, username: str, password: str
diff --git a/homeassistant/components/lupusec/const.py b/homeassistant/components/lupusec/const.py
index 489d878306d43e..4904bc481a7967 100644
--- a/homeassistant/components/lupusec/const.py
+++ b/homeassistant/components/lupusec/const.py
@@ -18,10 +18,6 @@
DOMAIN = "lupusec"
-INTEGRATION_TITLE = "Lupus Electronics LUPUSEC"
-ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=lupusec"}
-
-
TYPE_TRANSLATION = {
TYPE_WINDOW: "Fensterkontakt",
TYPE_DOOR: "Türkontakt",
diff --git a/homeassistant/components/lupusec/strings.json b/homeassistant/components/lupusec/strings.json
index 6fa59aaeb3db85..907232e0665c66 100644
--- a/homeassistant/components/lupusec/strings.json
+++ b/homeassistant/components/lupusec/strings.json
@@ -17,15 +17,5 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
- },
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The Lupus Electronics LUPUSEC YAML configuration import failed",
- "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "The Lupus Electronics LUPUSEC YAML configuration import failed",
- "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- }
}
}
diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py
index 1521a05df8e98a..45a51eb6df8b79 100644
--- a/homeassistant/components/lutron/__init__.py
+++ b/homeassistant/components/lutron/__init__.py
@@ -82,8 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
elif output.type == "CEILING_FAN_TYPE":
entry_data.fans.append((area.name, output))
platform = Platform.FAN
- # Deprecated, should be removed in 2024.8
- entry_data.lights.append((area.name, output))
elif output.is_dimmable:
entry_data.lights.append((area.name, output))
platform = Platform.LIGHT
diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py
index eb003fd431ab66..7e8829b231ca01 100644
--- a/homeassistant/components/lutron/light.py
+++ b/homeassistant/components/lutron/light.py
@@ -3,12 +3,10 @@
from __future__ import annotations
from collections.abc import Mapping
-import logging
from typing import Any
from pylutron import Output
-from homeassistant.components.automation import automations_with_entity
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_FLASH,
@@ -17,23 +15,13 @@
LightEntity,
LightEntityFeature,
)
-from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import (
- IssueSeverity,
- async_create_issue,
- create_issue,
-)
from . import DOMAIN, LutronData
from .entity import LutronDevice
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup_entry(
hass: HomeAssistant,
@@ -45,50 +33,13 @@ async def async_setup_entry(
Adds dimmers from the Main Repeater associated with the config_entry as
light entities.
"""
- ent_reg = er.async_get(hass)
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
- lights = []
-
- for area_name, device in entry_data.lights:
- if device.type == "CEILING_FAN_TYPE":
- # If this is a fan, check to see if this entity already exists.
- # If not, do not create a new one.
- entity_id = ent_reg.async_get_entity_id(
- Platform.LIGHT,
- DOMAIN,
- f"{entry_data.client.guid}_{device.uuid}",
- )
- if entity_id:
- entity_entry = ent_reg.async_get(entity_id)
- assert entity_entry
- if entity_entry.disabled:
- # If the entity exists and is disabled then we want to remove
- # the entity so that the user is using the new fan entity instead.
- ent_reg.async_remove(entity_id)
- else:
- lights.append(LutronLight(area_name, device, entry_data.client))
- entity_automations = automations_with_entity(hass, entity_id)
- entity_scripts = scripts_with_entity(hass, entity_id)
- for item in entity_automations + entity_scripts:
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_light_fan_{entity_id}_{item}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_light_fan_entity",
- translation_placeholders={
- "entity": entity_id,
- "info": item,
- },
- )
- else:
- lights.append(LutronLight(area_name, device, entry_data.client))
async_add_entities(
- lights,
+ (
+ LutronLight(area_name, device, entry_data.client)
+ for area_name, device in entry_data.lights
+ ),
True,
)
@@ -113,24 +64,8 @@ class LutronLight(LutronDevice, LightEntity):
_prev_brightness: int | None = None
_attr_name = None
- def __init__(self, area_name, lutron_device, controller) -> None:
- """Initialize the light."""
- super().__init__(area_name, lutron_device, controller)
- self._is_fan = lutron_device.type == "CEILING_FAN_TYPE"
-
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- if self._is_fan:
- create_issue(
- self.hass,
- DOMAIN,
- "deprecated_light_fan_on",
- breaks_in_ha_version="2024.8.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_light_fan_on",
- )
if flash := kwargs.get(ATTR_FLASH):
self._lutron_device.flash(0.5 if flash == "short" else 1.5)
else:
@@ -148,17 +83,6 @@ def turn_on(self, **kwargs: Any) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
- if self._is_fan:
- create_issue(
- self.hass,
- DOMAIN,
- "deprecated_light_fan_off",
- breaks_in_ha_version="2024.8.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_light_fan_off",
- )
args = {"new_level": 0}
if ATTR_TRANSITION in kwargs:
args["fade_time_seconds"] = kwargs[ATTR_TRANSITION]
diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json
index d5197375dc1843..770a453eb9e9f9 100644
--- a/homeassistant/components/lutron/strings.json
+++ b/homeassistant/components/lutron/strings.json
@@ -36,19 +36,5 @@
}
}
}
- },
- "issues": {
- "deprecated_light_fan_entity": {
- "title": "Detected Lutron fan entity created as a light",
- "description": "Fan entities have been added to the Lutron integration.\nWe detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new fan entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant."
- },
- "deprecated_light_fan_on": {
- "title": "The Lutron integration deprecated fan turned on",
- "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned on a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate."
- },
- "deprecated_light_fan_off": {
- "title": "The Lutron integration deprecated fan turned off",
- "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned off a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate."
- }
}
}
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index 48445f645aa74e..3c6348ed4da2a3 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
- "requirements": ["pylutron-caseta==0.20.0"],
+ "requirements": ["pylutron-caseta==0.21.1"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py
index 7c002229741954..e1eaed6602c185 100644
--- a/homeassistant/components/lyric/__init__.py
+++ b/homeassistant/components/lyric/__init__.py
@@ -192,8 +192,8 @@ def __init__(
) -> None:
"""Initialize the Honeywell Lyric accessory entity."""
super().__init__(coordinator, location, device, key)
- self._room = room
- self._accessory = accessory
+ self._room_id = room.id
+ self._accessory_id = accessory.id
@property
def device_info(self) -> DeviceInfo:
@@ -202,11 +202,25 @@ def device_info(self) -> DeviceInfo:
identifiers={
(
f"{dr.CONNECTION_NETWORK_MAC}_room_accessory",
- f"{self._mac_id}_room{self._room.id}_accessory{self._accessory.id}",
+ f"{self._mac_id}_room{self._room_id}_accessory{self._accessory_id}",
)
},
manufacturer="Honeywell",
model="RCHTSENSOR",
- name=f"{self._room.roomName} Sensor",
+ name=f"{self.room.roomName} Sensor",
via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id),
)
+
+ @property
+ def room(self) -> LyricRoom:
+ """Get the Lyric Device."""
+ return self.coordinator.data.rooms_dict[self._mac_id][self._room_id]
+
+ @property
+ def accessory(self) -> LyricAccessories:
+ """Get the Lyric Device."""
+ return next(
+ accessory
+ for accessory in self.room.accessories
+ if accessory.id == self._accessory_id
+ )
diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py
index 64f60fa6611f71..9f05354c399dfe 100644
--- a/homeassistant/components/lyric/sensor.py
+++ b/homeassistant/components/lyric/sensor.py
@@ -244,7 +244,6 @@ def __init__(
accessory,
f"{parentDevice.macID}_room{room.id}_acc{accessory.id}_{description.key}",
)
- self.room = room
self.entity_description = description
if description.device_class == SensorDeviceClass.TEMPERATURE:
if parentDevice.units == "Fahrenheit":
@@ -255,4 +254,4 @@ def __init__(
@property
def native_value(self) -> StateType | datetime:
"""Return the state."""
- return self.entity_description.value_fn(self._room, self._accessory)
+ return self.entity_description.value_fn(self.room, self.accessory)
diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py
index 6f0933ac879e60..047b8bb83e663d 100644
--- a/homeassistant/components/madvr/sensor.py
+++ b/homeassistant/components/madvr/sensor.py
@@ -277,4 +277,15 @@ def __init__(
@property
def native_value(self) -> float | str | None:
"""Return the state of the sensor."""
- return self.entity_description.value_fn(self.coordinator)
+ val = self.entity_description.value_fn(self.coordinator)
+ # check if sensor is enum
+ if self.entity_description.device_class == SensorDeviceClass.ENUM:
+ if (
+ self.entity_description.options
+ and val in self.entity_description.options
+ ):
+ return val
+ # return None for values that are not in the options
+ return None
+
+ return val
diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py
index b446ba3704e694..e0438342a547c1 100644
--- a/homeassistant/components/mailbox/__init__.py
+++ b/homeassistant/components/mailbox/__init__.py
@@ -92,7 +92,7 @@ async def async_setup_platform(
platform.get_handler, hass, p_config, discovery_info
)
else:
- raise HomeAssistantError("Invalid mailbox platform.")
+ raise HomeAssistantError("Invalid mailbox platform.") # noqa: TRY301
if mailbox is None:
_LOGGER.error("Failed to initialize mailbox platform %s", p_type)
diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py
index 5b344dd01ac01b..055e79867abf99 100644
--- a/homeassistant/components/manual/alarm_control_panel.py
+++ b/homeassistant/components/manual/alarm_control_panel.py
@@ -21,6 +21,7 @@
CONF_NAME,
CONF_PLATFORM,
CONF_TRIGGER_TIME,
+ CONF_UNIQUE_ID,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_ARMED_HOME,
@@ -122,6 +123,7 @@ def _state_schema(state):
{
vol.Required(CONF_PLATFORM): "manual",
vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Exclusive(CONF_CODE, "code validation"): cv.string,
vol.Exclusive(CONF_CODE_TEMPLATE, "code validation"): cv.template,
vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean,
@@ -179,6 +181,7 @@ def setup_platform(
ManualAlarm(
hass,
config[CONF_NAME],
+ config.get(CONF_UNIQUE_ID),
config.get(CONF_CODE),
config.get(CONF_CODE_TEMPLATE),
config.get(CONF_CODE_ARM_REQUIRED),
@@ -205,6 +208,7 @@ def __init__(
self,
hass,
name,
+ unique_id,
code,
code_template,
code_arm_required,
@@ -215,9 +219,9 @@ def __init__(
self._state = STATE_ALARM_DISARMED
self._hass = hass
self._attr_name = name
+ self._attr_unique_id = unique_id
if code_template:
self._code = code_template
- self._code.hass = hass
else:
self._code = code or None
self._attr_code_arm_required = code_arm_required
diff --git a/homeassistant/components/manual/manifest.json b/homeassistant/components/manual/manifest.json
index 7406ab2683027b..37ba45c2ddae4c 100644
--- a/homeassistant/components/manual/manifest.json
+++ b/homeassistant/components/manual/manifest.json
@@ -3,6 +3,7 @@
"name": "Manual Alarm Control Panel",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/manual",
+ "integration_type": "helper",
"iot_class": "calculated",
"quality_scale": "internal"
}
diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py
index 26946a2a45cd0f..8d447bbc8ac3a8 100644
--- a/homeassistant/components/manual_mqtt/alarm_control_panel.py
+++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py
@@ -273,7 +273,6 @@ def __init__(
self._attr_name = name
if code_template:
self._code = code_template
- self._code.hass = hass
else:
self._code = code or None
self._disarm_after_trigger = disarm_after_trigger
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
index 2fe379702ee701..77e66b6e45ca9c 100644
--- a/homeassistant/components/mastodon/__init__.py
+++ b/homeassistant/components/mastodon/__init__.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from dataclasses import dataclass
+
from mastodon.Mastodon import Mastodon, MastodonError
from homeassistant.config_entries import ConfigEntry
@@ -15,16 +17,33 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery
+from homeassistant.util import slugify
+
+from .const import CONF_BASE_URL, DOMAIN, LOGGER
+from .coordinator import MastodonCoordinator
+from .utils import construct_mastodon_username, create_mastodon_client
+
+PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
+
+
+@dataclass
+class MastodonData:
+ """Mastodon data type."""
+
+ client: Mastodon
+ instance: dict
+ account: dict
+ coordinator: MastodonCoordinator
+
-from .const import CONF_BASE_URL, DOMAIN
-from .utils import create_mastodon_client
+type MastodonConfigEntry = ConfigEntry[MastodonData]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
"""Set up Mastodon from a config entry."""
try:
- client, _, _ = await hass.async_add_executor_job(
+ client, instance, account = await hass.async_add_executor_job(
setup_mastodon,
entry,
)
@@ -34,6 +53,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
assert entry.unique_id
+ coordinator = MastodonCoordinator(hass, client)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = MastodonData(client, instance, account, coordinator)
+
await discovery.async_load_platform(
hass,
Platform.NOTIFY,
@@ -42,6 +67,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
{},
)
+ await hass.config_entries.async_forward_entry_setups(
+ entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(
+ entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
+ )
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Migrate old config."""
+
+ if entry.version == 1 and entry.minor_version == 1:
+ # Version 1.1 had the unique_id as client_id, this isn't necessarily unique
+ LOGGER.debug("Migrating config entry from version %s", entry.version)
+
+ try:
+ _, instance, account = await hass.async_add_executor_job(
+ setup_mastodon,
+ entry,
+ )
+ except MastodonError as ex:
+ LOGGER.error("Migration failed with error %s", ex)
+ return False
+
+ entry.minor_version = 2
+
+ hass.config_entries.async_update_entry(
+ entry,
+ unique_id=slugify(construct_mastodon_username(instance, account)),
+ )
+
+ LOGGER.info(
+ "Entry %s successfully migrated to version %s.%s",
+ entry.entry_id,
+ entry.version,
+ entry.minor_version,
+ )
+
return True
diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py
index 7d1c9396cbb01a..4e856275736671 100644
--- a/homeassistant/components/mastodon/config_flow.py
+++ b/homeassistant/components/mastodon/config_flow.py
@@ -20,6 +20,7 @@
TextSelectorType,
)
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import slugify
from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER
from .utils import construct_mastodon_username, create_mastodon_client
@@ -47,6 +48,7 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
+ MINOR_VERSION = 2
config_entry: ConfigEntry
def check_connection(
@@ -105,10 +107,6 @@ async def async_step_user(
"""Handle a flow initialized by the user."""
errors: dict[str, str] | None = None
if user_input:
- self._async_abort_entries_match(
- {CONF_CLIENT_ID: user_input[CONF_CLIENT_ID]}
- )
-
instance, account, errors = await self.hass.async_add_executor_job(
self.check_connection,
user_input[CONF_BASE_URL],
@@ -119,7 +117,8 @@ async def async_step_user(
if not errors:
name = construct_mastodon_username(instance, account)
- await self.async_set_unique_id(user_input[CONF_CLIENT_ID])
+ await self.async_set_unique_id(slugify(name))
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data=user_input,
@@ -148,7 +147,8 @@ async def async_step_import(self, import_config: ConfigType) -> ConfigFlowResult
)
if not errors:
- await self.async_set_unique_id(client_id)
+ name = construct_mastodon_username(instance, account)
+ await self.async_set_unique_id(slugify(name))
self._abort_if_unique_id_configured()
if not name:
diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py
index 3a9cf7462e6270..e0593d15d2cb6a 100644
--- a/homeassistant/components/mastodon/const.py
+++ b/homeassistant/components/mastodon/const.py
@@ -16,3 +16,6 @@
INSTANCE_URI: Final = "uri"
INSTANCE_DOMAIN: Final = "domain"
ACCOUNT_USERNAME: Final = "username"
+ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count"
+ACCOUNT_FOLLOWING_COUNT: Final = "following_count"
+ACCOUNT_STATUSES_COUNT: Final = "statuses_count"
diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py
new file mode 100644
index 00000000000000..f1332a0ea43dc7
--- /dev/null
+++ b/homeassistant/components/mastodon/coordinator.py
@@ -0,0 +1,35 @@
+"""Define an object to manage fetching Mastodon data."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Any
+
+from mastodon import Mastodon
+from mastodon.Mastodon import MastodonError
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import LOGGER
+
+
+class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Class to manage fetching Mastodon data."""
+
+ def __init__(self, hass: HomeAssistant, client: Mastodon) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass, logger=LOGGER, name="Mastodon", update_interval=timedelta(hours=1)
+ )
+ self.client = client
+
+ async def _async_update_data(self) -> dict[str, Any]:
+ try:
+ account: dict = await self.hass.async_add_executor_job(
+ self.client.account_verify_credentials
+ )
+ except MastodonError as ex:
+ raise UpdateFailed(ex) from ex
+
+ return account
diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py
new file mode 100644
index 00000000000000..7246ae9cf631b0
--- /dev/null
+++ b/homeassistant/components/mastodon/diagnostics.py
@@ -0,0 +1,35 @@
+"""Diagnostics support for the Mastodon integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import MastodonConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: MastodonConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ instance, account = await hass.async_add_executor_job(
+ get_diagnostics,
+ config_entry,
+ )
+
+ return {
+ "instance": instance,
+ "account": account,
+ }
+
+
+def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[dict, dict]:
+ """Get mastodon diagnostics."""
+ client = config_entry.runtime_data.client
+
+ instance = client.instance()
+ account = client.account_verify_credentials()
+
+ return instance, account
diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py
new file mode 100644
index 00000000000000..93d630627d7df8
--- /dev/null
+++ b/homeassistant/components/mastodon/entity.py
@@ -0,0 +1,48 @@
+"""Base class for Mastodon entities."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from . import MastodonConfigEntry
+from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION
+from .coordinator import MastodonCoordinator
+from .utils import construct_mastodon_username
+
+
+class MastodonEntity(CoordinatorEntity[MastodonCoordinator]):
+ """Defines a base Mastodon entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: MastodonCoordinator,
+ entity_description: EntityDescription,
+ data: MastodonConfigEntry,
+ ) -> None:
+ """Initialize Mastodon entity."""
+ super().__init__(coordinator)
+ unique_id = data.unique_id
+ assert unique_id is not None
+ self._attr_unique_id = f"{unique_id}_{entity_description.key}"
+
+ # Legacy yaml config default title is Mastodon, don't make name Mastodon Mastodon
+ name = "Mastodon"
+ if data.title != DEFAULT_NAME:
+ name = f"Mastodon {data.title}"
+
+ full_account_name = construct_mastodon_username(
+ data.runtime_data.instance, data.runtime_data.account
+ )
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ manufacturer="Mastodon gGmbH",
+ model=full_account_name,
+ entry_type=DeviceEntryType.SERVICE,
+ sw_version=data.runtime_data.instance[INSTANCE_VERSION],
+ name=name,
+ )
+
+ self.entity_description = entity_description
diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json
new file mode 100644
index 00000000000000..082e27a64c2eb2
--- /dev/null
+++ b/homeassistant/components/mastodon/icons.json
@@ -0,0 +1,15 @@
+{
+ "entity": {
+ "sensor": {
+ "followers": {
+ "default": "mdi:account-multiple"
+ },
+ "following": {
+ "default": "mdi:account-multiple"
+ },
+ "posts": {
+ "default": "mdi:message-text"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py
new file mode 100644
index 00000000000000..12acfc047435eb
--- /dev/null
+++ b/homeassistant/components/mastodon/sensor.py
@@ -0,0 +1,85 @@
+"""Mastodon platform for sensor components."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import MastodonConfigEntry
+from .const import (
+ ACCOUNT_FOLLOWERS_COUNT,
+ ACCOUNT_FOLLOWING_COUNT,
+ ACCOUNT_STATUSES_COUNT,
+)
+from .entity import MastodonEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class MastodonSensorEntityDescription(SensorEntityDescription):
+ """Describes Mastodon sensor entity."""
+
+ value_fn: Callable[[dict[str, Any]], StateType]
+
+
+ENTITY_DESCRIPTIONS = (
+ MastodonSensorEntityDescription(
+ key="followers",
+ translation_key="followers",
+ native_unit_of_measurement="accounts",
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT),
+ ),
+ MastodonSensorEntityDescription(
+ key="following",
+ translation_key="following",
+ native_unit_of_measurement="accounts",
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT),
+ ),
+ MastodonSensorEntityDescription(
+ key="posts",
+ translation_key="posts",
+ native_unit_of_measurement="posts",
+ state_class=SensorStateClass.TOTAL,
+ value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: MastodonConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the sensor platform for entity."""
+ coordinator = entry.runtime_data.coordinator
+
+ async_add_entities(
+ MastodonSensorEntity(
+ coordinator=coordinator,
+ entity_description=entity_description,
+ data=entry,
+ )
+ for entity_description in ENTITY_DESCRIPTIONS
+ )
+
+
+class MastodonSensorEntity(MastodonEntity, SensorEntity):
+ """A Mastodon sensor entity."""
+
+ entity_description: MastodonSensorEntityDescription
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the native value of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index e1124aad1a9297..906b67dd481a55 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -4,8 +4,8 @@
"user": {
"data": {
"base_url": "[%key:common::config_flow::data::url%]",
- "client_id": "Client Key",
- "client_secret": "Client Secret",
+ "client_id": "Client key",
+ "client_secret": "Client secret",
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
@@ -35,5 +35,18 @@
"title": "YAML import failed with unknown error",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
}
+ },
+ "entity": {
+ "sensor": {
+ "followers": {
+ "name": "Followers"
+ },
+ "following": {
+ "name": "Following"
+ },
+ "posts": {
+ "name": "Posts"
+ }
+ }
}
}
diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py
index 4c9af45e63fe8c..e1b488c0fce731 100644
--- a/homeassistant/components/matrix/__init__.py
+++ b/homeassistant/components/matrix/__init__.py
@@ -209,15 +209,22 @@ async def handle_startup(event: HassEvent) -> None:
await self._resolve_room_aliases(listening_rooms)
self._load_commands(commands)
await self._join_rooms()
+
# Sync once so that we don't respond to past events.
+ _LOGGER.debug("Starting initial sync for %s", self._mx_id)
await self._client.sync(timeout=30_000)
+ _LOGGER.debug("Finished initial sync for %s", self._mx_id)
self._client.add_event_callback(self._handle_room_message, RoomMessageText)
- await self._client.sync_forever(
- timeout=30_000,
- loop_sleep_time=1_000,
- ) # milliseconds.
+ _LOGGER.debug("Starting sync_forever for %s", self._mx_id)
+ self.hass.async_create_background_task(
+ self._client.sync_forever(
+ timeout=30_000,
+ loop_sleep_time=1_000,
+ ), # milliseconds.
+ name=f"{self.__class__.__name__}: sync_forever for '{self._mx_id}'",
+ )
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup)
@@ -342,7 +349,9 @@ async def _join_rooms(self) -> None:
async def _get_auth_tokens(self) -> JsonObjectType:
"""Read sorted authentication tokens from disk."""
try:
- return load_json_object(self._session_filepath)
+ return await self.hass.async_add_executor_job(
+ load_json_object, self._session_filepath
+ )
except HomeAssistantError as ex:
_LOGGER.warning(
"Loading authentication tokens from file '%s' failed: %s",
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index 7e854a854348a6..3c465c44f24f5c 100644
--- a/homeassistant/components/matrix/manifest.json
+++ b/homeassistant/components/matrix/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
- "requirements": ["matrix-nio==0.24.0", "Pillow==10.4.0"]
+ "requirements": ["matrix-nio==0.25.0", "Pillow==10.4.0"]
}
diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py
index 713aadf56208d5..ff00e4ee495f01 100644
--- a/homeassistant/components/matter/climate.py
+++ b/homeassistant/components/matter/climate.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import IntEnum
-from typing import TYPE_CHECKING, Any
+from typing import Any
from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
@@ -30,12 +30,6 @@
from .helpers import get_matter
from .models import MatterDiscoverySchema
-if TYPE_CHECKING:
- from matter_server.client import MatterClient
- from matter_server.client.models.node import MatterEndpoint
-
- from .discovery import MatterEntityInfo
-
TEMPERATURE_SCALING_FACTOR = 100
HVAC_SYSTEM_MODE_MAP = {
HVACMode.OFF: 0,
@@ -105,46 +99,9 @@ class MatterClimate(MatterEntity, ClimateEntity):
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_hvac_mode: HVACMode = HVACMode.OFF
+ _feature_map: int | None = None
_enable_turn_on_off_backwards_compatibility = False
- def __init__(
- self,
- matter_client: MatterClient,
- endpoint: MatterEndpoint,
- entity_info: MatterEntityInfo,
- ) -> None:
- """Initialize the Matter climate entity."""
- super().__init__(matter_client, endpoint, entity_info)
- product_id = self._endpoint.node.device_info.productID
- vendor_id = self._endpoint.node.device_info.vendorID
-
- # set hvac_modes based on feature map
- self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF]
- feature_map = int(
- self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap)
- )
- self._attr_supported_features = (
- ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
- )
- if feature_map & ThermostatFeature.kHeating:
- self._attr_hvac_modes.append(HVACMode.HEAT)
- if feature_map & ThermostatFeature.kCooling:
- self._attr_hvac_modes.append(HVACMode.COOL)
- if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES:
- self._attr_hvac_modes.append(HVACMode.DRY)
- if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES:
- self._attr_hvac_modes.append(HVACMode.FAN_ONLY)
- if feature_map & ThermostatFeature.kAutoMode:
- self._attr_hvac_modes.append(HVACMode.HEAT_COOL)
- # only enable temperature_range feature if the device actually supports that
-
- if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES:
- self._attr_supported_features |= (
- ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
- )
- if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF):
- self._attr_supported_features |= ClimateEntityFeature.TURN_ON
-
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
@@ -224,6 +181,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@callback
def _update_from_device(self) -> None:
"""Update from device."""
+ self._calculate_features()
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
@@ -319,6 +277,46 @@ def _update_from_device(self) -> None:
else:
self._attr_max_temp = DEFAULT_MAX_TEMP
+ @callback
+ def _calculate_features(
+ self,
+ ) -> None:
+ """Calculate features for HA Thermostat platform from Matter FeatureMap."""
+ feature_map = int(
+ self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap)
+ )
+ # NOTE: the featuremap can dynamically change, so we need to update the
+ # supported features if the featuremap changes.
+ # work out supported features and presets from matter featuremap
+ if self._feature_map == feature_map:
+ return
+ self._feature_map = feature_map
+ product_id = self._endpoint.node.device_info.productID
+ vendor_id = self._endpoint.node.device_info.vendorID
+ self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF]
+ self._attr_supported_features = (
+ ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
+ )
+ if feature_map & ThermostatFeature.kHeating:
+ self._attr_hvac_modes.append(HVACMode.HEAT)
+ if feature_map & ThermostatFeature.kCooling:
+ self._attr_hvac_modes.append(HVACMode.COOL)
+ if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES:
+ self._attr_hvac_modes.append(HVACMode.DRY)
+ if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES:
+ self._attr_hvac_modes.append(HVACMode.FAN_ONLY)
+ if feature_map & ThermostatFeature.kAutoMode:
+ self._attr_hvac_modes.append(HVACMode.HEAT_COOL)
+ # only enable temperature_range feature if the device actually supports that
+
+ if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES:
+ self._attr_supported_features |= (
+ ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ )
+ if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF):
+ self._attr_supported_features |= ClimateEntityFeature.TURN_ON
+
+ @callback
def _get_temperature_in_degrees(
self, attribute: type[clusters.ClusterAttributeDescriptor]
) -> float | None:
diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py
index 8e5ef617304d43..458a57538ebff4 100644
--- a/homeassistant/components/matter/fan.py
+++ b/homeassistant/components/matter/fan.py
@@ -59,6 +59,7 @@ class MatterFan(MatterEntity, FanEntity):
_last_known_preset_mode: str | None = None
_last_known_percentage: int = 0
_enable_turn_on_off_backwards_compatibility = False
+ _feature_map: int | None = None
async def async_turn_on(
self,
@@ -183,8 +184,7 @@ async def _set_wind_mode(self, wind_mode: str | None) -> None:
@callback
def _update_from_device(self) -> None:
"""Update from device."""
- if not hasattr(self, "_attr_preset_modes"):
- self._calculate_features()
+ self._calculate_features()
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
@@ -257,11 +257,17 @@ def _update_from_device(self) -> None:
def _calculate_features(
self,
) -> None:
- """Calculate features and preset modes for HA Fan platform from Matter attributes.."""
- # work out supported features and presets from matter featuremap
+ """Calculate features for HA Fan platform from Matter FeatureMap."""
feature_map = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.FeatureMap)
)
+ # NOTE: the featuremap can dynamically change, so we need to update the
+ # supported features if the featuremap changes.
+ # work out supported features and presets from matter featuremap
+ if self._feature_map == feature_map:
+ return
+ self._feature_map = feature_map
+ self._attr_supported_features = FanEntityFeature(0)
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py
index 65c3a535216487..6e9019c46fa8d9 100644
--- a/homeassistant/components/matter/light.py
+++ b/homeassistant/components/matter/light.py
@@ -51,17 +51,19 @@
# hw version (attributeKey 0/40/8)
# sw version (attributeKey 0/40/10)
TRANSITION_BLOCKLIST = (
- (4488, 514, "1.0", "1.0.0"),
- (4488, 260, "1.0", "1.0.0"),
- (5010, 769, "3.0", "1.0.0"),
- (4999, 25057, "1.0", "27.0"),
- (4448, 36866, "V1", "V1.0.0.5"),
- (5009, 514, "1.0", "1.0.0"),
(4107, 8475, "v1.0", "v1.0"),
(4107, 8550, "v1.0", "v1.0"),
(4107, 8551, "v1.0", "v1.0"),
- (4107, 8656, "v1.0", "v1.0"),
(4107, 8571, "v1.0", "v1.0"),
+ (4107, 8656, "v1.0", "v1.0"),
+ (4448, 36866, "V1", "V1.0.0.5"),
+ (4456, 1011, "1.0.0", "2.00.00"),
+ (4488, 260, "1.0", "1.0.0"),
+ (4488, 514, "1.0", "1.0.0"),
+ (4999, 24875, "1.0", "27.0"),
+ (4999, 25057, "1.0", "27.0"),
+ (5009, 514, "1.0", "1.0.0"),
+ (5010, 769, "3.0", "1.0.0"),
)
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index 31ae5e496ce08f..8adaecd67ad78e 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -38,7 +38,7 @@ async def async_setup_entry(
class MatterLock(MatterEntity, LockEntity):
"""Representation of a Matter lock."""
- features: int | None = None
+ _feature_map: int | None = None
_optimistic_timer: asyncio.TimerHandle | None = None
@property
@@ -61,22 +61,6 @@ def code_format(self) -> str | None:
return None
- @property
- def supports_door_position_sensor(self) -> bool:
- """Return True if the lock supports door position sensor."""
- if self.features is None:
- return False
-
- return bool(self.features & DoorLockFeature.kDoorPositionSensor)
-
- @property
- def supports_unbolt(self) -> bool:
- """Return True if the lock supports unbolt."""
- if self.features is None:
- return False
-
- return bool(self.features & DoorLockFeature.kUnbolt)
-
async def send_device_command(
self,
command: clusters.ClusterCommand,
@@ -120,7 +104,7 @@ async def async_unlock(self, **kwargs: Any) -> None:
)
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
- if self.supports_unbolt:
+ if self._attr_supported_features & LockEntityFeature.OPEN:
# if the lock reports it has separate unbolt support,
# the unlock command should unbolt only on the unlock command
# and unlatch on the HA 'open' command.
@@ -151,13 +135,8 @@ async def async_open(self, **kwargs: Any) -> None:
@callback
def _update_from_device(self) -> None:
"""Update the entity from the device."""
-
- if self.features is None:
- self.features = int(
- self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap)
- )
- if self.supports_unbolt:
- self._attr_supported_features = LockEntityFeature.OPEN
+ # always calculate the features as they can dynamically change
+ self._calculate_features()
lock_state = self.get_matter_attribute_value(
clusters.DoorLock.Attributes.LockState
@@ -197,6 +176,25 @@ def _reset_optimistic_state(self, write_state: bool = True) -> None:
if write_state:
self.async_write_ha_state()
+ @callback
+ def _calculate_features(
+ self,
+ ) -> None:
+ """Calculate features for HA Lock platform from Matter FeatureMap."""
+ feature_map = int(
+ self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap)
+ )
+ # NOTE: the featuremap can dynamically change, so we need to update the
+ # supported features if the featuremap changes.
+ if self._feature_map == feature_map:
+ return
+ self._feature_map = feature_map
+ supported_features = LockEntityFeature(0)
+ # determine if lock supports optional open/unbolt feature
+ if bool(feature_map & DoorLockFeature.kUnbolt):
+ supported_features |= LockEntityFeature.OPEN
+ self._attr_supported_features = supported_features
+
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py
index bf528077b32ea9..4a9ef3780d1cc1 100644
--- a/homeassistant/components/matter/select.py
+++ b/homeassistant/components/matter/select.py
@@ -2,7 +2,12 @@
from __future__ import annotations
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
from chip.clusters import Objects as clusters
+from chip.clusters.Types import Nullable
+from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
@@ -10,7 +15,7 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .entity import MatterEntity
+from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
@@ -22,7 +27,6 @@
| clusters.RvcRunMode
| clusters.RvcCleanMode
| clusters.DishwasherMode
- | clusters.MicrowaveOvenMode
| clusters.EnergyEvseMode
| clusters.DeviceEnergyManagementMode
)
@@ -38,7 +42,41 @@ async def async_setup_entry(
matter.register_platform_handler(Platform.SELECT, async_add_entities)
-class MatterModeSelectEntity(MatterEntity, SelectEntity):
+@dataclass(frozen=True)
+class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescription):
+ """Describe Matter select entities."""
+
+
+class MatterSelectEntity(MatterEntity, SelectEntity):
+ """Representation of a select entity from Matter Attribute read/write."""
+
+ entity_description: MatterSelectEntityDescription
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected mode."""
+ value_convert = self.entity_description.ha_to_native_value
+ if TYPE_CHECKING:
+ assert value_convert is not None
+ await self.matter_client.write_attribute(
+ node_id=self._endpoint.node.node_id,
+ attribute_path=create_attribute_path_from_attribute(
+ self._endpoint.endpoint_id, self._entity_info.primary_attribute
+ ),
+ value=value_convert(option),
+ )
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ value: Nullable | int | None
+ value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
+ value_convert = self.entity_description.measurement_to_ha
+ if TYPE_CHECKING:
+ assert value_convert is not None
+ self._attr_current_option = value_convert(value)
+
+
+class MatterModeSelectEntity(MatterSelectEntity):
"""Representation of a select entity from Matter (Mode) Cluster attribute(s)."""
async def async_select_option(self, option: str) -> None:
@@ -77,7 +115,7 @@ def _update_from_device(self) -> None:
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterModeSelect",
entity_category=EntityCategory.CONFIG,
translation_key="mode",
@@ -90,7 +128,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterOvenMode",
translation_key="mode",
),
@@ -102,7 +140,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterLaundryWasherMode",
translation_key="mode",
),
@@ -114,7 +152,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterRefrigeratorAndTemperatureControlledCabinetMode",
translation_key="mode",
),
@@ -126,7 +164,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterRvcRunMode",
translation_key="mode",
),
@@ -138,7 +176,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterRvcCleanMode",
translation_key="mode",
),
@@ -150,7 +188,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterDishwasherMode",
translation_key="mode",
),
@@ -162,19 +200,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
- key="MatterMicrowaveOvenMode",
- translation_key="mode",
- ),
- entity_class=MatterModeSelectEntity,
- required_attributes=(
- clusters.MicrowaveOvenMode.Attributes.CurrentMode,
- clusters.MicrowaveOvenMode.Attributes.SupportedModes,
- ),
- ),
- MatterDiscoverySchema(
- platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterEnergyEvseMode",
translation_key="mode",
),
@@ -186,7 +212,7 @@ def _update_from_device(self) -> None:
),
MatterDiscoverySchema(
platform=Platform.SELECT,
- entity_description=SelectEntityDescription(
+ entity_description=MatterSelectEntityDescription(
key="MatterDeviceEnergyManagementMode",
translation_key="mode",
),
@@ -196,4 +222,27 @@ def _update_from_device(self) -> None:
clusters.DeviceEnergyManagementMode.Attributes.SupportedModes,
),
),
+ MatterDiscoverySchema(
+ platform=Platform.SELECT,
+ entity_description=MatterSelectEntityDescription(
+ key="MatterStartUpOnOff",
+ entity_category=EntityCategory.CONFIG,
+ translation_key="startup_on_off",
+ options=["On", "Off", "Toggle", "Previous"],
+ measurement_to_ha=lambda x: {
+ 0: "Off",
+ 1: "On",
+ 2: "Toggle",
+ None: "Previous",
+ }[x],
+ ha_to_native_value=lambda x: {
+ "Off": 0,
+ "On": 1,
+ "Toggle": 2,
+ "Previous": None,
+ }[x],
+ ),
+ entity_class=MatterSelectEntity,
+ required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,),
+ ),
]
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index c23a2d6fe94390..e69c7ae309030c 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -134,6 +134,9 @@
"select": {
"mode": {
"name": "Mode"
+ },
+ "startup_on_off": {
+ "name": "Power-on behavior on Startup"
}
},
"sensor": {
diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py
index 4e6733db045442..736664e01019ba 100644
--- a/homeassistant/components/matter/update.py
+++ b/homeassistant/components/matter/update.py
@@ -8,7 +8,7 @@
from chip.clusters import Objects as clusters
from matter_server.common.errors import UpdateCheckError, UpdateError
-from matter_server.common.models import MatterSoftwareVersion
+from matter_server.common.models import MatterSoftwareVersion, UpdateSource
from homeassistant.components.update import (
ATTR_LATEST_VERSION,
@@ -18,7 +18,7 @@
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import STATE_ON, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -76,6 +76,12 @@ class MatterUpdate(MatterEntity, UpdateEntity):
_attr_should_poll = True
_software_update: MatterSoftwareVersion | None = None
_cancel_update: CALLBACK_TYPE | None = None
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL
+ | UpdateEntityFeature.PROGRESS
+ | UpdateEntityFeature.SPECIFIC_VERSION
+ | UpdateEntityFeature.RELEASE_NOTES
+ )
@callback
def _update_from_device(self) -> None:
@@ -84,16 +90,6 @@ def _update_from_device(self) -> None:
self._attr_installed_version = self.get_matter_attribute_value(
clusters.BasicInformation.Attributes.SoftwareVersionString
)
-
- if self.get_matter_attribute_value(
- clusters.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible
- ):
- self._attr_supported_features = (
- UpdateEntityFeature.INSTALL
- | UpdateEntityFeature.PROGRESS
- | UpdateEntityFeature.SPECIFIC_VERSION
- )
-
update_state: clusters.OtaSoftwareUpdateRequestor.Enums.UpdateStateEnum = (
self.get_matter_attribute_value(
clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState
@@ -133,9 +129,39 @@ async def async_update(self) -> None:
self._software_update = update_information
self._attr_latest_version = update_information.software_version_string
self._attr_release_url = update_information.release_notes_url
+
except UpdateCheckError as err:
raise HomeAssistantError(f"Error finding applicable update: {err}") from err
+ async def async_release_notes(self) -> str | None:
+ """Return full release notes.
+
+ This is suitable for a long changelog that does not fit in the release_summary
+ property. The returned string can contain markdown.
+ """
+ if self._software_update is None:
+ return None
+ if self.state != STATE_ON:
+ return None
+
+ release_notes = ""
+
+ # insert extra heavy warning case the update is not from the main net
+ if self._software_update.update_source != UpdateSource.MAIN_NET_DCL:
+ release_notes += (
+ "\n\n"
+ f"Update provided by {self._software_update.update_source.value}. "
+ "Installing this update is at your own risk and you may run into unexpected "
+ "problems such as the need to re-add and factory reset your device.\n\n"
+ )
+ return release_notes + (
+ "\n\nThe update process can take a while, "
+ "especially for battery powered devices. Please be patient and wait until the update "
+ "process is fully completed. Do not remove power from the device while it's updating. "
+ "The device may restart during the update process and be unavailable for several minutes."
+ "\n\n"
+ )
+
async def async_added_to_hass(self) -> None:
"""Call when the entity is added to hass."""
await super().async_added_to_hass()
@@ -172,6 +198,11 @@ async def async_install(
) -> None:
"""Install a new software version."""
+ if not self.get_matter_attribute_value(
+ clusters.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible
+ ):
+ raise HomeAssistantError("Device is not ready to install updates")
+
software_version: str | int | None = version
if self._software_update is not None and (
version is None or version == self._software_update.software_version_string
diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py
index 82cdc56e5d9945..d4a3a45f441480 100644
--- a/homeassistant/components/maxcube/__init__.py
+++ b/homeassistant/components/maxcube/__init__.py
@@ -98,7 +98,7 @@ def __init__(self, cube, scan_interval):
self.mutex = Lock()
self._updatets = time.monotonic()
- def update(self):
+ def update(self) -> None:
"""Pull the latest data from the MAX! Cube."""
# Acquire mutex to prevent simultaneous update from multiple threads
with self.mutex:
@@ -110,7 +110,7 @@ def update(self):
self.cube.update()
except TimeoutError:
_LOGGER.error("Max!Cube connection failed")
- return False
+ return
self._updatets = time.monotonic()
else:
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index cd312413db3ad3..2285d7bce7d99d 100644
--- a/homeassistant/components/media_extractor/manifest.json
+++ b/homeassistant/components/media_extractor/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
- "requirements": ["yt-dlp==2024.07.16"],
+ "requirements": ["yt-dlp==2024.08.06"],
"single_config_entry": true
}
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index d499ee8d6d3bc1..beb672a1e5851b 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -274,59 +274,59 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config)
component.async_register_entity_service(
- SERVICE_TURN_ON, {}, "async_turn_on", [MediaPlayerEntityFeature.TURN_ON]
+ SERVICE_TURN_ON, None, "async_turn_on", [MediaPlayerEntityFeature.TURN_ON]
)
component.async_register_entity_service(
- SERVICE_TURN_OFF, {}, "async_turn_off", [MediaPlayerEntityFeature.TURN_OFF]
+ SERVICE_TURN_OFF, None, "async_turn_off", [MediaPlayerEntityFeature.TURN_OFF]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON],
)
component.async_register_entity_service(
SERVICE_VOLUME_UP,
- {},
+ None,
"async_volume_up",
[MediaPlayerEntityFeature.VOLUME_SET, MediaPlayerEntityFeature.VOLUME_STEP],
)
component.async_register_entity_service(
SERVICE_VOLUME_DOWN,
- {},
+ None,
"async_volume_down",
[MediaPlayerEntityFeature.VOLUME_SET, MediaPlayerEntityFeature.VOLUME_STEP],
)
component.async_register_entity_service(
SERVICE_MEDIA_PLAY_PAUSE,
- {},
+ None,
"async_media_play_pause",
[MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE],
)
component.async_register_entity_service(
- SERVICE_MEDIA_PLAY, {}, "async_media_play", [MediaPlayerEntityFeature.PLAY]
+ SERVICE_MEDIA_PLAY, None, "async_media_play", [MediaPlayerEntityFeature.PLAY]
)
component.async_register_entity_service(
- SERVICE_MEDIA_PAUSE, {}, "async_media_pause", [MediaPlayerEntityFeature.PAUSE]
+ SERVICE_MEDIA_PAUSE, None, "async_media_pause", [MediaPlayerEntityFeature.PAUSE]
)
component.async_register_entity_service(
- SERVICE_MEDIA_STOP, {}, "async_media_stop", [MediaPlayerEntityFeature.STOP]
+ SERVICE_MEDIA_STOP, None, "async_media_stop", [MediaPlayerEntityFeature.STOP]
)
component.async_register_entity_service(
SERVICE_MEDIA_NEXT_TRACK,
- {},
+ None,
"async_media_next_track",
[MediaPlayerEntityFeature.NEXT_TRACK],
)
component.async_register_entity_service(
SERVICE_MEDIA_PREVIOUS_TRACK,
- {},
+ None,
"async_media_previous_track",
[MediaPlayerEntityFeature.PREVIOUS_TRACK],
)
component.async_register_entity_service(
SERVICE_CLEAR_PLAYLIST,
- {},
+ None,
"async_clear_playlist",
[MediaPlayerEntityFeature.CLEAR_PLAYLIST],
)
@@ -423,7 +423,7 @@ def _rewrite_enqueue(value: dict[str, Any]) -> dict[str, Any]:
[MediaPlayerEntityFeature.SHUFFLE_SET],
)
component.async_register_entity_service(
- SERVICE_UNJOIN, {}, "async_unjoin_player", [MediaPlayerEntityFeature.GROUPING]
+ SERVICE_UNJOIN, None, "async_unjoin_player", [MediaPlayerEntityFeature.GROUPING]
)
component.async_register_entity_service(
diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py
index 8a5d824112a4d3..edfab2a668f879 100644
--- a/homeassistant/components/media_player/intent.py
+++ b/homeassistant/components/media_player/intent.py
@@ -16,7 +16,7 @@
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers import intent
-from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN
+from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, MediaPlayerDeviceClass
from .const import MediaPlayerEntityFeature, MediaPlayerState
INTENT_MEDIA_PAUSE = "HassMediaPause"
@@ -69,6 +69,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
required_states={MediaPlayerState.PLAYING},
description="Skips a media player to the next item",
platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
),
)
intent.async_register(
@@ -82,6 +83,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
required_states={MediaPlayerState.PLAYING},
description="Replays the previous item for a media player",
platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
),
)
intent.async_register(
@@ -100,6 +102,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
},
description="Sets the volume of a media player",
platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
),
)
@@ -118,6 +121,7 @@ def __init__(self, last_paused: LastPaused) -> None:
required_states={MediaPlayerState.PLAYING},
description="Pauses a media player",
platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
)
self.last_paused = last_paused
@@ -153,6 +157,7 @@ def __init__(self, last_paused: LastPaused) -> None:
required_states={MediaPlayerState.PAUSED},
description="Resumes a media player",
platforms={DOMAIN},
+ device_classes={MediaPlayerDeviceClass},
)
self.last_paused = last_paused
diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py
index fcb0820a6f0cd0..0ad663faa2a37d 100644
--- a/homeassistant/components/melissa/climate.py
+++ b/homeassistant/components/melissa/climate.py
@@ -86,18 +86,21 @@ def fan_mode(self):
"""Return the current fan mode."""
if self._cur_settings is not None:
return self.melissa_fan_to_hass(self._cur_settings[self._api.FAN])
+ return None
@property
def current_temperature(self):
"""Return the current temperature."""
if self._data:
return self._data[self._api.TEMP]
+ return None
@property
def current_humidity(self):
"""Return the current humidity value."""
if self._data:
return self._data[self._api.HUMIDITY]
+ return None
@property
def target_temperature_step(self):
@@ -224,6 +227,7 @@ def hass_mode_to_melissa(self, mode):
if mode == HVACMode.FAN_ONLY:
return self._api.MODE_FAN
_LOGGER.warning("Melissa have no setting for %s mode", mode)
+ return None
def hass_fan_to_melissa(self, fan):
"""Translate hass fan modes to melissa modes."""
@@ -236,3 +240,4 @@ def hass_fan_to_melissa(self, fan):
if fan == FAN_HIGH:
return self._api.FAN_HIGH
_LOGGER.warning("Melissa have no setting for %s fan mode", fan)
+ return None
diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py
index 95ed2ba9089831..0eb3742a878c25 100644
--- a/homeassistant/components/meraki/device_tracker.py
+++ b/homeassistant/components/meraki/device_tracker.py
@@ -88,6 +88,7 @@ async def post(self, request):
_LOGGER.debug("No observations found")
return None
self._handle(request.app[KEY_HASS], data)
+ return None
@callback
def _handle(self, hass, data):
diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py
index 943d30fccfdbff..8305547afd32bf 100644
--- a/homeassistant/components/meteo_france/weather.py
+++ b/homeassistant/components/meteo_france/weather.py
@@ -165,6 +165,7 @@ def wind_bearing(self):
wind_bearing = self.coordinator.data.current_forecast["wind"]["direction"]
if wind_bearing != -1:
return wind_bearing
+ return None
def _forecast(self, mode: str) -> list[Forecast]:
"""Return the forecast."""
diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json
index 9a41e8a3062cb9..4de91f6a43119c 100644
--- a/homeassistant/components/meteoalarm/manifest.json
+++ b/homeassistant/components/meteoalarm/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/meteoalarm",
"iot_class": "cloud_polling",
"loggers": ["meteoalertapi"],
- "requirements": ["meteoalertapi==0.3.0"]
+ "requirements": ["meteoalertapi==0.3.1"]
}
diff --git a/homeassistant/components/mfi/manifest.json b/homeassistant/components/mfi/manifest.json
index db9cb547b28e8f..b569009d40023e 100644
--- a/homeassistant/components/mfi/manifest.json
+++ b/homeassistant/components/mfi/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/mfi",
"iot_class": "local_polling",
"loggers": ["mficlient"],
- "requirements": ["mficlient==0.3.0"]
+ "requirements": ["mficlient==0.5.0"]
}
diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py
index 9f2b40bf1c8d3d..cecf96a6c3e3e7 100644
--- a/homeassistant/components/mikrotik/__init__.py
+++ b/homeassistant/components/mikrotik/__init__.py
@@ -4,14 +4,12 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import ATTR_MANUFACTURER, DOMAIN
from .coordinator import MikrotikDataUpdateCoordinator, get_api
from .errors import CannotConnect, LoginError
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
PLATFORMS = [Platform.DEVICE_TRACKER]
type MikrotikConfigEntry = ConfigEntry[MikrotikDataUpdateCoordinator]
diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py
index e2cbcdf9ed1be8..e5470cc3313781 100644
--- a/homeassistant/components/minio/__init__.py
+++ b/homeassistant/components/minio/__init__.py
@@ -127,7 +127,6 @@ def _setup_listener(listener_conf):
def _render_service_value(service, key):
value = service.data[key]
- value.hass = hass
return value.async_render(parse_result=False)
def put_file(service: ServiceCall) -> None:
diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py
index a8577cc596d0b2..80893e0cbfad6f 100644
--- a/homeassistant/components/mobile_app/__init__.py
+++ b/homeassistant/components/mobile_app/__init__.py
@@ -124,12 +124,18 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
):
await async_create_cloud_hook(hass, webhook_id, entry)
- if (
- CONF_CLOUDHOOK_URL not in entry.data
- and cloud.async_active_subscription(hass)
- and cloud.async_is_connected(hass)
- ):
- await async_create_cloud_hook(hass, webhook_id, entry)
+ if cloud.async_is_logged_in(hass):
+ if (
+ CONF_CLOUDHOOK_URL not in entry.data
+ and cloud.async_active_subscription(hass)
+ and cloud.async_is_connected(hass)
+ ):
+ await async_create_cloud_hook(hass, webhook_id, entry)
+ elif CONF_CLOUDHOOK_URL in entry.data:
+ # If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
+ data = dict(entry.data)
+ data.pop(CONF_CLOUDHOOK_URL)
+ hass.config_entries.async_update_entry(entry, data=data)
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))
diff --git a/homeassistant/components/mobile_app/device_action.py b/homeassistant/components/mobile_app/device_action.py
index bebdef0e91723a..dccff926b34b5a 100644
--- a/homeassistant/components/mobile_app/device_action.py
+++ b/homeassistant/components/mobile_app/device_action.py
@@ -64,7 +64,6 @@ async def async_call_action_from_config(
continue
value_template = config[key]
- template.attach(hass, value_template)
try:
service_data[key] = template.render_complex(value_template, variables)
diff --git a/homeassistant/components/mobile_app/timers.py b/homeassistant/components/mobile_app/timers.py
index 93b4ac53be5303..e092298c5d7292 100644
--- a/homeassistant/components/mobile_app/timers.py
+++ b/homeassistant/components/mobile_app/timers.py
@@ -39,6 +39,8 @@ def async_handle_timer_event(
# Android
"channel": "Timers",
"importance": "high",
+ "ttl": 0,
+ "priority": "high",
# iOS
"push": {
"interruption-level": "time-sensitive",
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index 82caa772ac44e5..e70b9de50f0d46 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -76,8 +76,8 @@
_LOGGER = logging.getLogger(__name__)
-ConfEntry = namedtuple("ConfEntry", "call_type attr func_name")
-RunEntry = namedtuple("RunEntry", "attr func")
+ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024
+RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024
PB_CALL = [
ConfEntry(
CALL_TYPE_COIL,
diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py
index 90ef0b5f083285..e1120094d01094 100644
--- a/homeassistant/components/modbus/validators.py
+++ b/homeassistant/components/modbus/validators.py
@@ -46,7 +46,7 @@
_LOGGER = logging.getLogger(__name__)
-ENTRY = namedtuple(
+ENTRY = namedtuple( # noqa: PYI024
"ENTRY",
[
"struct_id",
@@ -60,7 +60,7 @@
OPTIONAL = "O"
DEMANDED = "D"
-PARM_IS_LEGAL = namedtuple(
+PARM_IS_LEGAL = namedtuple( # noqa: PYI024
"PARM_IS_LEGAL",
[
"count",
diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py
index c00549c327a50d..e34038c7be73cb 100644
--- a/homeassistant/components/modern_forms/fan.py
+++ b/homeassistant/components/modern_forms/fan.py
@@ -56,7 +56,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_CLEAR_FAN_SLEEP_TIMER,
- {},
+ None,
"async_clear_fan_sleep_timer",
)
diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py
index e758a50e77e853..4c210038694e62 100644
--- a/homeassistant/components/modern_forms/light.py
+++ b/homeassistant/components/modern_forms/light.py
@@ -61,7 +61,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_CLEAR_LIGHT_SLEEP_TIMER,
- {},
+ None,
"async_clear_light_sleep_timer",
)
diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json
index 8b81645700455a..d9d17eb8abcf3c 100644
--- a/homeassistant/components/monzo/manifest.json
+++ b/homeassistant/components/monzo/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/monzo",
"iot_class": "cloud_polling",
- "requirements": ["monzopy==1.3.0"]
+ "requirements": ["monzopy==1.3.2"]
}
diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py
new file mode 100644
index 00000000000000..30f1cd53e6fa9e
--- /dev/null
+++ b/homeassistant/components/motion_blinds/button.py
@@ -0,0 +1,71 @@
+"""Support for Motionblinds button entity using their WLAN API."""
+
+from __future__ import annotations
+
+from motionblinds.motion_blinds import LimitStatus, MotionBlind
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
+from .coordinator import DataUpdateCoordinatorMotionBlinds
+from .entity import MotionCoordinatorEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Perform the setup for Motionblinds."""
+ entities: list[ButtonEntity] = []
+ motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
+ coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
+
+ for blind in motion_gateway.device_list.values():
+ if blind.limit_status == LimitStatus.Limit3Detected.name:
+ entities.append(MotionGoFavoriteButton(coordinator, blind))
+ entities.append(MotionSetFavoriteButton(coordinator, blind))
+
+ async_add_entities(entities)
+
+
+class MotionGoFavoriteButton(MotionCoordinatorEntity, ButtonEntity):
+ """Button entity to go to the favorite position of a blind."""
+
+ _attr_translation_key = "go_favorite"
+
+ def __init__(
+ self, coordinator: DataUpdateCoordinatorMotionBlinds, blind: MotionBlind
+ ) -> None:
+ """Initialize the Motion Button."""
+ super().__init__(coordinator, blind)
+ self._attr_unique_id = f"{blind.mac}-go-favorite"
+
+ async def async_press(self) -> None:
+ """Execute the button action."""
+ async with self._api_lock:
+ await self.hass.async_add_executor_job(self._blind.Go_favorite_position)
+ await self.async_request_position_till_stop()
+
+
+class MotionSetFavoriteButton(MotionCoordinatorEntity, ButtonEntity):
+ """Button entity to set the favorite position of a blind to the current position."""
+
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_translation_key = "set_favorite"
+
+ def __init__(
+ self, coordinator: DataUpdateCoordinatorMotionBlinds, blind: MotionBlind
+ ) -> None:
+ """Initialize the Motion Button."""
+ super().__init__(coordinator, blind)
+ self._attr_unique_id = f"{blind.mac}-set-favorite"
+
+ async def async_press(self) -> None:
+ """Execute the button action."""
+ async with self._api_lock:
+ await self.hass.async_add_executor_job(self._blind.Set_favorite_position)
diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py
index e089fd17943b75..96067d7ceb07f5 100644
--- a/homeassistant/components/motion_blinds/const.py
+++ b/homeassistant/components/motion_blinds/const.py
@@ -6,7 +6,7 @@
MANUFACTURER = "Motionblinds, Coulisse B.V."
DEFAULT_GATEWAY_NAME = "Motionblinds Gateway"
-PLATFORMS = [Platform.COVER, Platform.SENSOR]
+PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR]
CONF_WAIT_FOR_PUSH = "wait_for_push"
CONF_INTERFACE = "interface"
diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py
index 2cbee96adb71c8..72b78915badffb 100644
--- a/homeassistant/components/motion_blinds/cover.py
+++ b/homeassistant/components/motion_blinds/cover.py
@@ -5,7 +5,7 @@
import logging
from typing import Any
-from motionblinds import DEVICE_TYPES_WIFI, BlindType
+from motionblinds import BlindType
import voluptuous as vol
from homeassistant.components.cover import (
@@ -16,10 +16,9 @@
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant
+from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -31,8 +30,6 @@
KEY_GATEWAY,
SERVICE_SET_ABSOLUTE_POSITION,
UPDATE_DELAY_STOP,
- UPDATE_INTERVAL_MOVING,
- UPDATE_INTERVAL_MOVING_WIFI,
)
from .entity import MotionCoordinatorEntity
@@ -179,14 +176,6 @@ def __init__(self, coordinator, blind, device_class):
"""Initialize the blind."""
super().__init__(coordinator, blind)
- self._requesting_position: CALLBACK_TYPE | None = None
- self._previous_positions = []
-
- if blind.device_type in DEVICE_TYPES_WIFI:
- self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
- else:
- self._update_interval_moving = UPDATE_INTERVAL_MOVING
-
self._attr_device_class = device_class
self._attr_unique_id = blind.mac
@@ -218,47 +207,6 @@ def is_closed(self) -> bool | None:
return None
return self._blind.position == 100
- async def async_scheduled_update_request(self, *_):
- """Request a state update from the blind at a scheduled point in time."""
- # add the last position to the list and keep the list at max 2 items
- self._previous_positions.append(self.current_cover_position)
- if len(self._previous_positions) > 2:
- del self._previous_positions[: len(self._previous_positions) - 2]
-
- async with self._api_lock:
- await self.hass.async_add_executor_job(self._blind.Update_trigger)
-
- self.async_write_ha_state()
-
- if len(self._previous_positions) < 2 or not all(
- self.current_cover_position == prev_position
- for prev_position in self._previous_positions
- ):
- # keep updating the position @self._update_interval_moving until the position does not change.
- self._requesting_position = async_call_later(
- self.hass,
- self._update_interval_moving,
- self.async_scheduled_update_request,
- )
- else:
- self._previous_positions = []
- self._requesting_position = None
-
- async def async_request_position_till_stop(self, delay=None):
- """Request the position of the blind every self._update_interval_moving seconds until it stops moving."""
- if delay is None:
- delay = self._update_interval_moving
-
- self._previous_positions = []
- if self.current_cover_position is None:
- return
- if self._requesting_position is not None:
- self._requesting_position()
-
- self._requesting_position = async_call_later(
- self.hass, delay, self.async_scheduled_update_request
- )
-
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
async with self._api_lock:
diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py
index 4734d4d9a65671..483a638a0eb2b0 100644
--- a/homeassistant/components/motion_blinds/entity.py
+++ b/homeassistant/components/motion_blinds/entity.py
@@ -5,8 +5,10 @@
from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway
from motionblinds.motion_blinds import MotionBlind
+from homeassistant.core import CALLBACK_TYPE
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -15,6 +17,8 @@
DOMAIN,
KEY_GATEWAY,
MANUFACTURER,
+ UPDATE_INTERVAL_MOVING,
+ UPDATE_INTERVAL_MOVING_WIFI,
)
from .coordinator import DataUpdateCoordinatorMotionBlinds
from .gateway import device_name
@@ -36,6 +40,14 @@ def __init__(
self._blind = blind
self._api_lock = coordinator.api_lock
+ self._requesting_position: CALLBACK_TYPE | None = None
+ self._previous_positions: list[int | dict | None] = []
+
+ if blind.device_type in DEVICE_TYPES_WIFI:
+ self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
+ else:
+ self._update_interval_moving = UPDATE_INTERVAL_MOVING
+
if blind.device_type in DEVICE_TYPES_GATEWAY:
gateway = blind
else:
@@ -95,3 +107,44 @@ async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe when removed."""
self._blind.Remove_callback(self.unique_id)
await super().async_will_remove_from_hass()
+
+ async def async_scheduled_update_request(self, *_) -> None:
+ """Request a state update from the blind at a scheduled point in time."""
+ # add the last position to the list and keep the list at max 2 items
+ self._previous_positions.append(self._blind.position)
+ if len(self._previous_positions) > 2:
+ del self._previous_positions[: len(self._previous_positions) - 2]
+
+ async with self._api_lock:
+ await self.hass.async_add_executor_job(self._blind.Update_trigger)
+
+ self.coordinator.async_update_listeners()
+
+ if len(self._previous_positions) < 2 or not all(
+ self._blind.position == prev_position
+ for prev_position in self._previous_positions
+ ):
+ # keep updating the position @self._update_interval_moving until the position does not change.
+ self._requesting_position = async_call_later(
+ self.hass,
+ self._update_interval_moving,
+ self.async_scheduled_update_request,
+ )
+ else:
+ self._previous_positions = []
+ self._requesting_position = None
+
+ async def async_request_position_till_stop(self, delay: int | None = None) -> None:
+ """Request the position of the blind every self._update_interval_moving seconds until it stops moving."""
+ if delay is None:
+ delay = self._update_interval_moving
+
+ self._previous_positions = []
+ if self._blind.position is None:
+ return
+ if self._requesting_position is not None:
+ self._requesting_position()
+
+ self._requesting_position = async_call_later(
+ self.hass, delay, self.async_scheduled_update_request
+ )
diff --git a/homeassistant/components/motion_blinds/icons.json b/homeassistant/components/motion_blinds/icons.json
index a61c36e3f00e6a..9e1cd613e5b53d 100644
--- a/homeassistant/components/motion_blinds/icons.json
+++ b/homeassistant/components/motion_blinds/icons.json
@@ -1,4 +1,14 @@
{
+ "entity": {
+ "button": {
+ "go_favorite": {
+ "default": "mdi:star"
+ },
+ "set_favorite": {
+ "default": "mdi:star-cog"
+ }
+ }
+ },
"services": {
"set_absolute_position": "mdi:set-square"
}
diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json
index 0f9241db7b4a49..e1e12cf67295af 100644
--- a/homeassistant/components/motion_blinds/manifest.json
+++ b/homeassistant/components/motion_blinds/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"iot_class": "local_push",
"loggers": ["motionblinds"],
- "requirements": ["motionblinds==0.6.23"]
+ "requirements": ["motionblinds==0.6.24"]
}
diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json
index cb9468c3a27d5c..ddbf928462aeb0 100644
--- a/homeassistant/components/motion_blinds/strings.json
+++ b/homeassistant/components/motion_blinds/strings.json
@@ -62,6 +62,14 @@
}
},
"entity": {
+ "button": {
+ "go_favorite": {
+ "name": "Go to favorite position"
+ },
+ "set_favorite": {
+ "name": "Set current position as favorite"
+ }
+ },
"cover": {
"top": {
"name": "Top"
diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py
index da5eb36d494a68..d84f7b43c04fbb 100644
--- a/homeassistant/components/motioneye/camera.py
+++ b/homeassistant/components/motioneye/camera.py
@@ -136,7 +136,7 @@ def camera_add(camera: dict[str, Any]) -> None:
)
platform.async_register_entity_service(
SERVICE_SNAPSHOT,
- {},
+ None,
"async_request_snapshot",
)
diff --git a/homeassistant/components/mpd/config_flow.py b/homeassistant/components/mpd/config_flow.py
index 619fb8936e2f78..f37ebe5e5e809a 100644
--- a/homeassistant/components/mpd/config_flow.py
+++ b/homeassistant/components/mpd/config_flow.py
@@ -32,7 +32,9 @@ async def async_step_user(
"""Handle a flow initiated by the user."""
errors = {}
if user_input:
- self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ self._async_abort_entries_match(
+ {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
+ )
client = MPDClient()
client.timeout = 30
client.idletimeout = 10
diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py
index 3538b1c7973b05..92f0f5cfcc49d3 100644
--- a/homeassistant/components/mpd/media_player.py
+++ b/homeassistant/components/mpd/media_player.py
@@ -86,7 +86,7 @@ async def async_setup_platform(
)
if (
result["type"] is FlowResultType.CREATE_ENTRY
- or result["reason"] == "single_instance_allowed"
+ or result["reason"] == "already_configured"
):
async_create_issue(
hass,
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 5f7f1b1d330a23..b2adb7665fc07d 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -90,6 +90,7 @@
PublishPayloadType,
ReceiveMessage,
ReceivePayloadType,
+ convert_outgoing_mqtt_payload,
)
from .subscription import ( # noqa: F401
EntitySubscription,
@@ -115,6 +116,7 @@
ATTR_TOPIC_TEMPLATE = "topic_template"
ATTR_PAYLOAD_TEMPLATE = "payload_template"
+ATTR_EVALUATE_PAYLOAD = "evaluate_payload"
MAX_RECONNECT_WAIT = 300 # seconds
@@ -166,6 +168,7 @@
vol.Exclusive(ATTR_TOPIC_TEMPLATE, CONF_TOPIC): cv.string,
vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string,
vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string,
+ vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean,
vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
},
@@ -295,6 +298,7 @@ async def async_publish_service(call: ServiceCall) -> None:
msg_topic: str | None = call.data.get(ATTR_TOPIC)
msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE)
payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD)
+ evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False)
payload_template: str | None = call.data.get(ATTR_PAYLOAD_TEMPLATE)
qos: int = call.data[ATTR_QOS]
retain: bool = call.data[ATTR_RETAIN]
@@ -303,8 +307,7 @@ async def async_publish_service(call: ServiceCall) -> None:
# has been deprecated with HA Core 2024.8.0
# and will be removed with HA Core 2025.2.0
rendered_topic: Any = MqttCommandTemplate(
- template.Template(msg_topic_template),
- hass=hass,
+ template.Template(msg_topic_template, hass),
).async_render()
ir.async_create_issue(
hass,
@@ -353,8 +356,11 @@ async def async_publish_service(call: ServiceCall) -> None:
},
)
payload = MqttCommandTemplate(
- template.Template(payload_template), hass=hass
+ template.Template(payload_template, hass)
).async_render()
+ elif evaluate_payload:
+ # Convert quoted binary literal to raw data
+ payload = convert_outgoing_mqtt_payload(payload)
if TYPE_CHECKING:
assert msg_topic is not None
diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py
index d4c381d2ec6220..b9aab7e6273a05 100644
--- a/homeassistant/components/mqtt/abbreviations.py
+++ b/homeassistant/components/mqtt/abbreviations.py
@@ -269,6 +269,7 @@
"name": "name",
"mf": "manufacturer",
"mdl": "model",
+ "mdl_id": "model_id",
"hw": "hw_version",
"sw": "sw_version",
"sa": "suggested_area",
diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py
index b55ee45d30e5de..04b8fc28978f56 100644
--- a/homeassistant/components/mqtt/mixins.py
+++ b/homeassistant/components/mqtt/mixins.py
@@ -16,6 +16,7 @@
ATTR_HW_VERSION,
ATTR_MANUFACTURER,
ATTR_MODEL,
+ ATTR_MODEL_ID,
ATTR_NAME,
ATTR_SERIAL_NUMBER,
ATTR_SUGGESTED_AREA,
@@ -25,6 +26,7 @@
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_MODEL,
+ CONF_MODEL_ID,
CONF_NAME,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
@@ -1067,6 +1069,9 @@ def device_info_from_specifications(
if CONF_MODEL in specifications:
info[ATTR_MODEL] = specifications[CONF_MODEL]
+ if CONF_MODEL_ID in specifications:
+ info[ATTR_MODEL_ID] = specifications[CONF_MODEL_ID]
+
if CONF_NAME in specifications:
info[ATTR_NAME] = specifications[CONF_NAME]
diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py
index 04191dc244c829..34c1f3049441b2 100644
--- a/homeassistant/components/mqtt/models.py
+++ b/homeassistant/components/mqtt/models.py
@@ -12,7 +12,7 @@
from typing import TYPE_CHECKING, Any, TypedDict
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.helpers import template
from homeassistant.helpers.entity import Entity
@@ -51,6 +51,22 @@ class PayloadSentinel(StrEnum):
type PublishPayloadType = str | bytes | int | float | None
+def convert_outgoing_mqtt_payload(
+ payload: PublishPayloadType,
+) -> PublishPayloadType:
+ """Ensure correct raw MQTT payload is passed as bytes for publishing."""
+ if isinstance(payload, str) and payload.startswith(("b'", 'b"')):
+ try:
+ native_object = literal_eval(payload)
+ except (ValueError, TypeError, SyntaxError, MemoryError):
+ pass
+ else:
+ if isinstance(native_object, bytes):
+ return native_object
+
+ return payload
+
+
@dataclass
class PublishMessage:
"""MQTT Message for publishing."""
@@ -159,22 +175,13 @@ def __init__(
self,
command_template: template.Template | None,
*,
- hass: HomeAssistant | None = None,
entity: Entity | None = None,
) -> None:
"""Instantiate a command template."""
self._template_state: template.TemplateStateFromEntityId | None = None
self._command_template = command_template
- if command_template is None:
- return
-
self._entity = entity
- command_template.hass = hass
-
- if entity:
- command_template.hass = entity.hass
-
@callback
def async_render(
self,
@@ -182,22 +189,6 @@ def async_render(
variables: TemplateVarsType = None,
) -> PublishPayloadType:
"""Render or convert the command template with given value or variables."""
-
- def _convert_outgoing_payload(
- payload: PublishPayloadType,
- ) -> PublishPayloadType:
- """Ensure correct raw MQTT payload is passed as bytes for publishing."""
- if isinstance(payload, str):
- try:
- native_object = literal_eval(payload)
- if isinstance(native_object, bytes):
- return native_object
-
- except (ValueError, TypeError, SyntaxError, MemoryError):
- pass
-
- return payload
-
if self._command_template is None:
return value
@@ -219,7 +210,7 @@ def _convert_outgoing_payload(
self._command_template,
)
try:
- return _convert_outgoing_payload(
+ return convert_outgoing_mqtt_payload(
self._command_template.async_render(values, parse_result=False)
)
except TemplateError as exc:
@@ -270,7 +261,6 @@ def __init__(
self,
value_template: template.Template | None,
*,
- hass: HomeAssistant | None = None,
entity: Entity | None = None,
config_attributes: TemplateVarsType = None,
) -> None:
@@ -278,15 +268,8 @@ def __init__(
self._template_state: template.TemplateStateFromEntityId | None = None
self._value_template = value_template
self._config_attributes = config_attributes
- if value_template is None:
- return
-
- value_template.hass = hass
self._entity = entity
- if entity:
- value_template.hass = entity.hass
-
@callback
def async_render_with_possible_json_value(
self,
diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py
index 208b944f7803b0..7953f1ce3c0226 100644
--- a/homeassistant/components/mqtt/schemas.py
+++ b/homeassistant/components/mqtt/schemas.py
@@ -11,6 +11,7 @@
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_MODEL,
+ CONF_MODEL_ID,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIQUE_ID,
@@ -147,6 +148,7 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType
),
vol.Optional(CONF_MANUFACTURER): cv.string,
vol.Optional(CONF_MODEL): cv.string,
+ vol.Optional(CONF_MODEL_ID): cv.string,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_HW_VERSION): cv.string,
vol.Optional(CONF_SERIAL_NUMBER): cv.string,
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 4a41f4868318b0..e983f1b66f3910 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -260,7 +260,7 @@ def _update_state(self, msg: ReceiveMessage) -> None:
return
try:
if (payload_datetime := dt_util.parse_datetime(payload)) is None:
- raise ValueError
+ raise ValueError # noqa: TRY301
except ValueError:
_LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic)
self._attr_native_value = None
@@ -280,7 +280,7 @@ def _update_last_reset(self, msg: ReceiveMessage) -> None:
try:
last_reset = dt_util.parse_datetime(str(payload))
if last_reset is None:
- raise ValueError
+ raise ValueError # noqa: TRY301
self._attr_last_reset = last_reset
except ValueError:
_LOGGER.warning(
diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml
index ee5e4ff56e8161..c5e4f372bd6896 100644
--- a/homeassistant/components/mqtt/services.yaml
+++ b/homeassistant/components/mqtt/services.yaml
@@ -12,6 +12,11 @@ publish:
example: "The temperature is {{ states('sensor.temperature') }}"
selector:
template:
+ evaluate_payload:
+ advanced: true
+ default: false
+ selector:
+ boolean:
qos:
advanced: true
default: 0
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 93131376154933..c786d7e08a157c 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -230,6 +230,10 @@
"name": "Publish",
"description": "Publishes a message to an MQTT topic.",
"fields": {
+ "evaluate_payload": {
+ "name": "Evaluate payload",
+ "description": "When `payload` is a Python bytes literal, evaluate the bytes literal and publish the raw data."
+ },
"topic": {
"name": "Topic",
"description": "Topic to publish to."
diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py
index 47d1370e31d89c..d45c8536fc8263 100644
--- a/homeassistant/components/mqtt/tag.py
+++ b/homeassistant/components/mqtt/tag.py
@@ -119,8 +119,7 @@ def __init__(
self.hass = hass
self._sub_state: dict[str, EntitySubscription] | None = None
self._value_template = MqttValueTemplate(
- config.get(CONF_VALUE_TEMPLATE),
- hass=self.hass,
+ config.get(CONF_VALUE_TEMPLATE)
).async_render_with_possible_json_value
MqttDiscoveryDeviceUpdateMixin.__init__(
@@ -137,8 +136,7 @@ async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None:
return
self._config = config
self._value_template = MqttValueTemplate(
- config.get(CONF_VALUE_TEMPLATE),
- hass=self.hass,
+ config.get(CONF_VALUE_TEMPLATE)
).async_render_with_possible_json_value
update_device(self.hass, self._config_entry, config)
await self.subscribe_topics()
diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py
index 91ac404a07a36d..3f7f03d7f19881 100644
--- a/homeassistant/components/mqtt/trigger.py
+++ b/homeassistant/components/mqtt/trigger.py
@@ -60,10 +60,10 @@ async def async_attach_trigger(
trigger_data: TriggerData = trigger_info["trigger_data"]
command_template: Callable[
[PublishPayloadType, TemplateVarsType], PublishPayloadType
- ] = MqttCommandTemplate(config.get(CONF_PAYLOAD), hass=hass).async_render
+ ] = MqttCommandTemplate(config.get(CONF_PAYLOAD)).async_render
value_template: Callable[[ReceivePayloadType, str], ReceivePayloadType]
value_template = MqttValueTemplate(
- config.get(CONF_VALUE_TEMPLATE), hass=hass
+ config.get(CONF_VALUE_TEMPLATE)
).async_render_with_possible_json_value
encoding: str | None = config[CONF_ENCODING] or None
qos: int = config[CONF_QOS]
@@ -75,7 +75,6 @@ async def async_attach_trigger(
wanted_payload = command_template(None, variables)
topic_template: Template = config[CONF_TOPIC]
- topic_template.hass = hass
topic = topic_template.async_render(variables, limited=True, parse_result=False)
mqtt.util.valid_subscribe_topic(topic)
diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py
index ed18b890a24def..8ebcbe0e2fe2fe 100644
--- a/homeassistant/components/mysensors/__init__.py
+++ b/homeassistant/components/mysensors/__init__.py
@@ -10,7 +10,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from .const import (
@@ -32,9 +31,6 @@
DATA_HASS_CONFIG = "hass_config"
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an instance of the MySensors integration.
diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py
index a0a1c92c682306..b8a3769308ae14 100644
--- a/homeassistant/components/mysensors/binary_sensor.py
+++ b/homeassistant/components/mysensors/binary_sensor.py
@@ -104,8 +104,8 @@ class MySensorsBinarySensor(mysensors.device.MySensorsChildEntity, BinarySensorE
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Set up the instance."""
super().__init__(*args, **kwargs)
- pres = self.gateway.const.Presentation
- self.entity_description = SENSORS[pres(self.child_type).name]
+ presentation = self.gateway.const.Presentation
+ self.entity_description = SENSORS[presentation(self.child_type).name]
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py
index f060f3313dcfea..74dc99e76d3ae4 100644
--- a/homeassistant/components/mysensors/helpers.py
+++ b/homeassistant/components/mysensors/helpers.py
@@ -168,11 +168,9 @@ def invalid_msg(
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
) -> str:
"""Return a message for an invalid child during schema validation."""
- pres = gateway.const.Presentation
+ presentation = gateway.const.Presentation
set_req = gateway.const.SetReq
- return (
- f"{pres(child.type).name} requires value_type {set_req[value_type_name].name}"
- )
+ return f"{presentation(child.type).name} requires value_type {set_req[value_type_name].name}"
def validate_set_msg(
@@ -202,10 +200,10 @@ def validate_child(
) -> defaultdict[Platform, list[DevId]]:
"""Validate a child. Returns a dict mapping hass platform names to list of DevId."""
validated: defaultdict[Platform, list[DevId]] = defaultdict(list)
- pres: type[IntEnum] = gateway.const.Presentation
+ presentation: type[IntEnum] = gateway.const.Presentation
set_req: type[IntEnum] = gateway.const.SetReq
child_type_name: SensorType | None = next(
- (member.name for member in pres if member.value == child.type), None
+ (member.name for member in presentation if member.value == child.type), None
)
if not child_type_name:
_LOGGER.warning("Child type %s is not supported", child.type)
diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py
index a6a91c12a81403..9c6c0de89e663e 100644
--- a/homeassistant/components/mysensors/sensor.py
+++ b/homeassistant/components/mysensors/sensor.py
@@ -318,9 +318,9 @@ def _get_entity_description(self) -> SensorEntityDescription | None:
entity_description = SENSORS.get(set_req(self.value_type).name)
if not entity_description:
- pres = self.gateway.const.Presentation
+ presentation = self.gateway.const.Presentation
entity_description = SENSORS.get(
- f"{set_req(self.value_type).name}_{pres(self.child_type).name}"
+ f"{set_req(self.value_type).name}_{presentation(self.child_type).name}"
)
return entity_description
diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py
index 66ea2cc967975f..17a1da75a9603f 100644
--- a/homeassistant/components/mystrom/binary_sensor.py
+++ b/homeassistant/components/mystrom/binary_sensor.py
@@ -67,6 +67,7 @@ async def _handle(self, hass, data):
else:
new_state = self.buttons[entity_id].state == "off"
self.buttons[entity_id].async_on_update(new_state)
+ return None
class MyStromBinarySensor(BinarySensorEntity):
diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py
index a8202434ce5ac2..730a9aff7656c9 100644
--- a/homeassistant/components/ness_alarm/__init__.py
+++ b/homeassistant/components/ness_alarm/__init__.py
@@ -44,7 +44,7 @@
SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed"
SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed"
-ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"])
+ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"]) # noqa: PYI024
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
ZONE_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
index d3ba571e65a138..fbe5ddb65346c2 100644
--- a/homeassistant/components/nest/manifest.json
+++ b/homeassistant/components/nest/manifest.json
@@ -20,5 +20,5 @@
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
"quality_scale": "platinum",
- "requirements": ["google-nest-sdm==4.0.5"]
+ "requirements": ["google-nest-sdm==4.0.6"]
}
diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py
index 6c481806e4f8d3..71501e72552503 100644
--- a/homeassistant/components/nest/media_source.py
+++ b/homeassistant/components/nest/media_source.py
@@ -228,7 +228,7 @@ async def async_remove_media(self, media_key: str) -> None:
def remove_media(filename: str) -> None:
if not os.path.exists(filename):
- return None
+ return
_LOGGER.debug("Removing event media from disk store: %s", filename)
os.remove(filename)
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index e257c7a89eab35..c2953b9d49d741 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -174,7 +174,7 @@ def _create_entity(netatmo_device: NetatmoRoom) -> None:
)
platform.async_register_entity_service(
SERVICE_CLEAR_TEMPERATURE_SETTING,
- {},
+ None,
"_async_service_clear_temperature_setting",
)
diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py
index f5627f5e56bd9c..54bfef5e1da8dd 100644
--- a/homeassistant/components/netio/switch.py
+++ b/homeassistant/components/netio/switch.py
@@ -38,7 +38,7 @@
DEFAULT_PORT = 1234
DEFAULT_USERNAME = "admin"
-Device = namedtuple("Device", ["netio", "entities"])
+Device = namedtuple("Device", ["netio", "entities"]) # noqa: PYI024
DEVICES: dict[str, Device] = {}
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py
index 4d0993d3569a89..9bc76fdcfdc12f 100644
--- a/homeassistant/components/nexia/__init__.py
+++ b/homeassistant/components/nexia/__init__.py
@@ -12,7 +12,6 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
from .const import CONF_BRAND, DOMAIN, PLATFORMS
from .coordinator import NexiaDataUpdateCoordinator
@@ -21,8 +20,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: NexiaConfigEntry) -> bool:
"""Configure the base Nexia device for Home Assistant."""
diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json
index 27fec1bfba969e..d22ba66d860dad 100644
--- a/homeassistant/components/nextbus/manifest.json
+++ b/homeassistant/components/nextbus/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nextbus",
"iot_class": "cloud_polling",
"loggers": ["py_nextbus"],
- "requirements": ["py-nextbusnext==2.0.3"]
+ "requirements": ["py-nextbusnext==2.0.4"]
}
diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py
index 9e328e8e58de13..a487a3f1414dc8 100644
--- a/homeassistant/components/nextcloud/__init__.py
+++ b/homeassistant/components/nextcloud/__init__.py
@@ -19,14 +19,12 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import entity_registry as er
-from .const import DOMAIN
from .coordinator import NextcloudDataUpdateCoordinator
PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/nice_go/__init__.py b/homeassistant/components/nice_go/__init__.py
new file mode 100644
index 00000000000000..33c81b709664d0
--- /dev/null
+++ b/homeassistant/components/nice_go/__init__.py
@@ -0,0 +1,43 @@
+"""The Nice G.O. integration."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import NiceGOUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS: list[Platform] = [Platform.COVER]
+
+type NiceGOConfigEntry = ConfigEntry[NiceGOUpdateCoordinator]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bool:
+ """Set up Nice G.O. from a config entry."""
+
+ coordinator = NiceGOUpdateCoordinator(hass)
+
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+
+ entry.async_create_background_task(
+ hass,
+ coordinator.client_listen(),
+ "nice_go_websocket_task",
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bool:
+ """Unload a config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ await entry.runtime_data.api.close()
+
+ return unload_ok
diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py
new file mode 100644
index 00000000000000..9d2c1c05518550
--- /dev/null
+++ b/homeassistant/components/nice_go/config_flow.py
@@ -0,0 +1,68 @@
+"""Config flow for Nice G.O. integration."""
+
+from __future__ import annotations
+
+from datetime import datetime
+import logging
+from typing import Any
+
+from nice_go import AuthFailedError, NiceGOApi
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN_CREATION_TIME, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+
+class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Nice G.O."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ await self.async_set_unique_id(user_input[CONF_EMAIL])
+ self._abort_if_unique_id_configured()
+
+ hub = NiceGOApi()
+
+ try:
+ refresh_token = await hub.authenticate(
+ user_input[CONF_EMAIL],
+ user_input[CONF_PASSWORD],
+ async_get_clientsession(self.hass),
+ )
+ except AuthFailedError:
+ errors["base"] = "invalid_auth"
+ except Exception: # noqa: BLE001
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_EMAIL],
+ data={
+ CONF_EMAIL: user_input[CONF_EMAIL],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ CONF_REFRESH_TOKEN: refresh_token,
+ CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
+ },
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/nice_go/const.py b/homeassistant/components/nice_go/const.py
new file mode 100644
index 00000000000000..c3caa92c8be7ac
--- /dev/null
+++ b/homeassistant/components/nice_go/const.py
@@ -0,0 +1,13 @@
+"""Constants for the Nice G.O. integration."""
+
+from datetime import timedelta
+
+DOMAIN = "nice_go"
+
+# Configuration
+CONF_SITE_ID = "site_id"
+CONF_DEVICE_ID = "device_id"
+CONF_REFRESH_TOKEN = "refresh_token"
+CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
+
+REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30)
diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py
new file mode 100644
index 00000000000000..196ed0a211c256
--- /dev/null
+++ b/homeassistant/components/nice_go/coordinator.py
@@ -0,0 +1,220 @@
+"""DataUpdateCoordinator for Nice G.O."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime
+import json
+import logging
+from typing import Any
+
+from nice_go import (
+ BARRIER_STATUS,
+ ApiError,
+ AuthFailedError,
+ BarrierState,
+ ConnectionState,
+ NiceGOApi,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+ CONF_REFRESH_TOKEN,
+ CONF_REFRESH_TOKEN_CREATION_TIME,
+ DOMAIN,
+ REFRESH_TOKEN_EXPIRY_TIME,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class NiceGODevice:
+ """Nice G.O. device dataclass."""
+
+ id: str
+ name: str
+ barrier_status: str
+ light_status: bool
+ fw_version: str
+ connected: bool
+
+
+class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
+ """DataUpdateCoordinator for Nice G.O."""
+
+ config_entry: ConfigEntry
+ organization_id: str
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize DataUpdateCoordinator for Nice G.O."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Nice G.O.",
+ )
+
+ self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN]
+ self.refresh_token_creation_time = self.config_entry.data[
+ CONF_REFRESH_TOKEN_CREATION_TIME
+ ]
+ self.email = self.config_entry.data[CONF_EMAIL]
+ self.password = self.config_entry.data[CONF_PASSWORD]
+ self.api = NiceGOApi()
+ self.ws_connected = False
+
+ async def _parse_barrier(self, barrier_state: BarrierState) -> NiceGODevice | None:
+ """Parse barrier data."""
+
+ device_id = barrier_state.deviceId
+ name = barrier_state.reported["displayName"]
+ if barrier_state.reported["migrationStatus"] == "NOT_STARTED":
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"firmware_update_required_{device_id}",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="firmware_update_required",
+ translation_placeholders={"device_name": name},
+ )
+ return None
+ ir.async_delete_issue(
+ self.hass, DOMAIN, f"firmware_update_required_{device_id}"
+ )
+ barrier_status_raw = [
+ int(x) for x in barrier_state.reported["barrierStatus"].split(",")
+ ]
+
+ if BARRIER_STATUS[int(barrier_status_raw[2])] == "STATIONARY":
+ barrier_status = "open" if barrier_status_raw[0] == 1 else "closed"
+ else:
+ barrier_status = BARRIER_STATUS[int(barrier_status_raw[2])].lower()
+
+ light_status = barrier_state.reported["lightStatus"].split(",")[0] == "1"
+ fw_version = barrier_state.reported["deviceFwVersion"]
+ if barrier_state.connectionState:
+ connected = barrier_state.connectionState.connected
+ else:
+ connected = False
+
+ return NiceGODevice(
+ id=device_id,
+ name=name,
+ barrier_status=barrier_status,
+ light_status=light_status,
+ fw_version=fw_version,
+ connected=connected,
+ )
+
+ async def _async_update_data(self) -> dict[str, NiceGODevice]:
+ return self.data
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ async with asyncio.timeout(10):
+ expiry_time = (
+ self.refresh_token_creation_time
+ + REFRESH_TOKEN_EXPIRY_TIME.total_seconds()
+ )
+ try:
+ if datetime.now().timestamp() >= expiry_time:
+ await self._update_refresh_token()
+ else:
+ await self.api.authenticate_refresh(
+ self.refresh_token, async_get_clientsession(self.hass)
+ )
+ _LOGGER.debug("Authenticated with Nice G.O. API")
+
+ barriers = await self.api.get_all_barriers()
+ parsed_barriers = [
+ await self._parse_barrier(barrier.state) for barrier in barriers
+ ]
+
+ # Parse the barriers and save them in a dictionary
+ devices = {
+ barrier.id: barrier for barrier in parsed_barriers if barrier
+ }
+ self.organization_id = await barriers[0].get_attr("organization")
+ except AuthFailedError as e:
+ raise ConfigEntryAuthFailed from e
+ except ApiError as e:
+ raise UpdateFailed from e
+ else:
+ self.async_set_updated_data(devices)
+
+ async def _update_refresh_token(self) -> None:
+ """Update the refresh token with Nice G.O. API."""
+ _LOGGER.debug("Updating the refresh token with Nice G.O. API")
+ try:
+ refresh_token = await self.api.authenticate(
+ self.email, self.password, async_get_clientsession(self.hass)
+ )
+ except AuthFailedError as e:
+ _LOGGER.exception("Authentication failed")
+ raise ConfigEntryAuthFailed from e
+ except ApiError as e:
+ _LOGGER.exception("API error")
+ raise UpdateFailed from e
+
+ self.refresh_token = refresh_token
+ data = {
+ **self.config_entry.data,
+ CONF_REFRESH_TOKEN: refresh_token,
+ CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
+ }
+ self.hass.config_entries.async_update_entry(self.config_entry, data=data)
+
+ async def client_listen(self) -> None:
+ """Listen to the websocket for updates."""
+ self.api.event(self.on_connected)
+ self.api.event(self.on_data)
+ try:
+ await self.api.connect(reconnect=True)
+ except ApiError:
+ _LOGGER.exception("API error")
+
+ if not self.hass.is_stopping:
+ await asyncio.sleep(5)
+ await self.client_listen()
+
+ async def on_data(self, data: dict[str, Any]) -> None:
+ """Handle incoming data from the websocket."""
+ _LOGGER.debug("Received data from the websocket")
+ _LOGGER.debug(data)
+ raw_data = data["data"]["devicesStatesUpdateFeed"]["item"]
+ parsed_data = await self._parse_barrier(
+ BarrierState(
+ deviceId=raw_data["deviceId"],
+ desired=json.loads(raw_data["desired"]),
+ reported=json.loads(raw_data["reported"]),
+ connectionState=ConnectionState(
+ connected=raw_data["connectionState"]["connected"],
+ updatedTimestamp=raw_data["connectionState"]["updatedTimestamp"],
+ )
+ if raw_data["connectionState"]
+ else None,
+ version=raw_data["version"],
+ timestamp=raw_data["timestamp"],
+ )
+ )
+ if parsed_data is None:
+ return
+
+ data_copy = self.data.copy()
+ data_copy[parsed_data.id] = parsed_data
+
+ self.async_set_updated_data(data_copy)
+
+ async def on_connected(self) -> None:
+ """Handle the websocket connection."""
+ _LOGGER.debug("Connected to the websocket")
+ await self.api.subscribe(self.organization_id)
diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py
new file mode 100644
index 00000000000000..70bd4b136a5c2c
--- /dev/null
+++ b/homeassistant/components/nice_go/cover.py
@@ -0,0 +1,72 @@
+"""Cover entity for Nice G.O."""
+
+from typing import Any
+
+from homeassistant.components.cover import (
+ CoverDeviceClass,
+ CoverEntity,
+ CoverEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import NiceGOConfigEntry
+from .entity import NiceGOEntity
+
+PARALLEL_UPDATES = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: NiceGOConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Nice G.O. cover."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ NiceGOCoverEntity(coordinator, device_id, device_data.name, "cover")
+ for device_id, device_data in coordinator.data.items()
+ )
+
+
+class NiceGOCoverEntity(NiceGOEntity, CoverEntity):
+ """Representation of a Nice G.O. cover."""
+
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ _attr_name = None
+ _attr_device_class = CoverDeviceClass.GARAGE
+
+ @property
+ def is_closed(self) -> bool:
+ """Return if cover is closed."""
+ return self.data.barrier_status == "closed"
+
+ @property
+ def is_opened(self) -> bool:
+ """Return if cover is open."""
+ return self.data.barrier_status == "open"
+
+ @property
+ def is_opening(self) -> bool:
+ """Return if cover is opening."""
+ return self.data.barrier_status == "opening"
+
+ @property
+ def is_closing(self) -> bool:
+ """Return if cover is closing."""
+ return self.data.barrier_status == "closing"
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close the garage door."""
+ if self.is_closed:
+ return
+
+ await self.coordinator.api.close_barrier(self._device_id)
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the garage door."""
+ if self.is_opened:
+ return
+
+ await self.coordinator.api.open_barrier(self._device_id)
diff --git a/homeassistant/components/nice_go/entity.py b/homeassistant/components/nice_go/entity.py
new file mode 100644
index 00000000000000..5af4b9c8731ecb
--- /dev/null
+++ b/homeassistant/components/nice_go/entity.py
@@ -0,0 +1,41 @@
+"""Base entity for Nice G.O."""
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import NiceGODevice, NiceGOUpdateCoordinator
+
+
+class NiceGOEntity(CoordinatorEntity[NiceGOUpdateCoordinator]):
+ """Common base for Nice G.O. entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: NiceGOUpdateCoordinator,
+ device_id: str,
+ device_name: str,
+ sub_device_id: str,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self._attr_unique_id = f"{device_id}-{sub_device_id}"
+ self._device_id = device_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device_id)},
+ name=device_name,
+ sw_version=coordinator.data[device_id].fw_version,
+ )
+
+ @property
+ def data(self) -> NiceGODevice:
+ """Return the Nice G.O. device."""
+ return self.coordinator.data[self._device_id]
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return super().available and self.data.connected
diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json
new file mode 100644
index 00000000000000..e86c68b491f9db
--- /dev/null
+++ b/homeassistant/components/nice_go/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nice_go",
+ "name": "Nice G.O.",
+ "codeowners": ["@IceBotYT"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/nice_go",
+ "iot_class": "cloud_push",
+ "loggers": ["nice-go"],
+ "requirements": ["nice-go==0.1.6"]
+}
diff --git a/homeassistant/components/nice_go/strings.json b/homeassistant/components/nice_go/strings.json
new file mode 100644
index 00000000000000..8d21f6d9740c2a
--- /dev/null
+++ b/homeassistant/components/nice_go/strings.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "issues": {
+ "firmware_update_required": {
+ "title": "Firmware update required",
+ "description": "Your device ({device_name}) requires a firmware update on the Nice G.O. app in order to work with this integration. Please update the firmware on the Nice G.O. app and reconfigure this integration."
+ }
+ }
+}
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
index 360b45cceedfb7..b2d41f3a41ef5e 100644
--- a/homeassistant/components/niko_home_control/light.py
+++ b/homeassistant/components/niko_home_control/light.py
@@ -120,3 +120,4 @@ def get_state(self, aid):
if state["id"] == aid:
return state["value1"]
_LOGGER.error("Failed to retrieve state off unknown light")
+ return None
diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py
index 1fc7836ecd858e..31c7b8e4d70f0e 100644
--- a/homeassistant/components/notify/__init__.py
+++ b/homeassistant/components/notify/__init__.py
@@ -91,14 +91,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def persistent_notification(service: ServiceCall) -> None:
"""Send notification via the built-in persistent_notify integration."""
message: Template = service.data[ATTR_MESSAGE]
- message.hass = hass
check_templates_warn(hass, message)
title = None
title_tpl: Template | None
if title_tpl := service.data.get(ATTR_TITLE):
check_templates_warn(hass, title_tpl)
- title_tpl.hass = hass
title = title_tpl.async_render(parse_result=False)
notification_id = None
diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py
index b3871d858e887f..dcb148a99f5939 100644
--- a/homeassistant/components/notify/legacy.py
+++ b/homeassistant/components/notify/legacy.py
@@ -105,7 +105,7 @@ async def async_setup_platform(
platform.get_service, hass, p_config, discovery_info
)
else:
- raise HomeAssistantError("Invalid notify platform.")
+ raise HomeAssistantError("Invalid notify platform.") # noqa: TRY301
if notify_service is None:
# Platforms can decide not to create a service based
@@ -259,7 +259,6 @@ async def _async_notify_message_service(self, service: ServiceCall) -> None:
title: Template | None
if title := service.data.get(ATTR_TITLE):
check_templates_warn(self.hass, title)
- title.hass = self.hass
kwargs[ATTR_TITLE] = title.async_render(parse_result=False)
if self.registered_targets.get(service.service) is not None:
@@ -268,7 +267,6 @@ async def _async_notify_message_service(self, service: ServiceCall) -> None:
kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET)
check_templates_warn(self.hass, message)
- message.hass = self.hass
kwargs[ATTR_MESSAGE] = message.async_render(parse_result=False)
kwargs[ATTR_DATA] = service.data.get(ATTR_DATA)
diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py
index 1793a0cfd474e9..00bded5c3a0b79 100644
--- a/homeassistant/components/notion/__init__.py
+++ b/homeassistant/components/notion/__init__.py
@@ -14,11 +14,7 @@
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import (
- config_validation as cv,
- device_registry as dr,
- entity_registry as er,
-)
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -49,7 +45,6 @@
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
# Define a map of old-API task types to new-API listener types:
TASK_TYPE_TO_LISTENER_MAP: dict[str, ListenerKind] = {
diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py
index 8eeee1f3f95796..fdb49688ebadf7 100644
--- a/homeassistant/components/nuheat/__init__.py
+++ b/homeassistant/components/nuheat/__init__.py
@@ -11,15 +11,12 @@
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
def _get_thermostat(api, serial_number):
"""Authenticate and create the thermostat object."""
diff --git a/homeassistant/components/number/significant_change.py b/homeassistant/components/number/significant_change.py
index 14cb2246615eae..e8cdd78e3215db 100644
--- a/homeassistant/components/number/significant_change.py
+++ b/homeassistant/components/number/significant_change.py
@@ -44,7 +44,6 @@ def async_check_significant_change(
if (device_class := new_attrs.get(ATTR_DEVICE_CLASS)) is None:
return None
- absolute_change: float | None = None
percentage_change: float | None = None
# special for temperature
@@ -83,11 +82,8 @@ def async_check_significant_change(
# Old state was invalid, we should report again
return True
- if absolute_change is not None and percentage_change is not None:
+ if percentage_change is not None:
return _absolute_and_relative_change(
float(old_state), float(new_state), absolute_change, percentage_change
)
- if absolute_change is not None:
- return check_absolute_change(
- float(old_state), float(new_state), absolute_change
- )
+ return check_absolute_change(float(old_state), float(new_state), absolute_change)
diff --git a/homeassistant/components/nws/services.yaml b/homeassistant/components/nws/services.yaml
index 0d439a9d278388..a3d241c775d716 100644
--- a/homeassistant/components/nws/services.yaml
+++ b/homeassistant/components/nws/services.yaml
@@ -2,6 +2,7 @@ get_forecasts_extra:
target:
entity:
domain: weather
+ integration: nws
fields:
type:
required: true
diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py
index 61b3f98739c117..d47ac78c9d08a7 100644
--- a/homeassistant/components/nzbget/__init__.py
+++ b/homeassistant/components/nzbget/__init__.py
@@ -23,7 +23,6 @@
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
SPEED_LIMIT_SCHEMA = vol.Schema(
{vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.positive_int}
diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py
index ae0acef107778d..c0423b258f0455 100644
--- a/homeassistant/components/ollama/conversation.py
+++ b/homeassistant/components/ollama/conversation.py
@@ -63,6 +63,34 @@ def _format_tool(
return {"type": "function", "function": tool_spec}
+def _fix_invalid_arguments(value: Any) -> Any:
+ """Attempt to repair incorrectly formatted json function arguments.
+
+ Small models (for example llama3.1 8B) may produce invalid argument values
+ which we attempt to repair here.
+ """
+ if not isinstance(value, str):
+ return value
+ if (value.startswith("[") and value.endswith("]")) or (
+ value.startswith("{") and value.endswith("}")
+ ):
+ try:
+ return json.loads(value)
+ except json.decoder.JSONDecodeError:
+ pass
+ return value
+
+
+def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]:
+ """Rewrite ollama tool arguments.
+
+ This function improves tool use quality by fixing common mistakes made by
+ small local tool use models. This will repair invalid json arguments and
+ omit unnecessary arguments with empty values that will fail intent parsing.
+ """
+ return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v}
+
+
class OllamaConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -78,6 +106,10 @@ def __init__(self, entry: ConfigEntry) -> None:
self._history: dict[str, MessageHistory] = {}
self._attr_name = entry.title
self._attr_unique_id = entry.entry_id
+ if self.entry.options.get(CONF_LLM_HASS_API):
+ self._attr_supported_features = (
+ conversation.ConversationEntityFeature.CONTROL
+ )
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
@@ -86,6 +118,9 @@ async def async_added_to_hass(self) -> None:
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
+ self.entry.async_on_unload(
+ self.entry.add_update_listener(self._async_entry_update_listener)
+ )
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -255,7 +290,7 @@ async def async_process(
for tool_call in tool_calls:
tool_input = llm.ToolInput(
tool_name=tool_call["function"]["name"],
- tool_args=tool_call["function"]["arguments"],
+ tool_args=_parse_tool_args(tool_call["function"]["arguments"]),
)
_LOGGER.debug(
"Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
@@ -271,7 +306,7 @@ async def async_process(
_LOGGER.debug("Tool response: %s", tool_response)
message_history.messages.append(
ollama.Message(
- role=MessageRole.TOOL.value, # type: ignore[typeddict-item]
+ role=MessageRole.TOOL.value,
content=json.dumps(tool_response),
)
)
@@ -306,3 +341,10 @@ def _trim_history(self, message_history: MessageHistory, max_messages: int) -> N
message_history.messages = [
message_history.messages[0]
] + message_history.messages[drop_index:]
+
+ async def _async_entry_update_listener(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Handle options update."""
+ # Reload as we update device info + entity name + supported features
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json
index 4d4321b8e3ded3..64224eb06fbfa1 100644
--- a/homeassistant/components/ollama/manifest.json
+++ b/homeassistant/components/ollama/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/ollama",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["ollama==0.3.0"]
+ "requirements": ["ollama==0.3.1"]
}
diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py
index dd42049e3d0225..a7109a6d6ecbd0 100644
--- a/homeassistant/components/openai_conversation/conversation.py
+++ b/homeassistant/components/openai_conversation/conversation.py
@@ -23,6 +23,7 @@
from homeassistant.components import assist_pipeline, conversation
from homeassistant.components.conversation import trace
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
@@ -109,6 +110,9 @@ async def async_added_to_hass(self) -> None:
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
+ self.entry.async_on_unload(
+ self.entry.add_update_listener(self._async_entry_update_listener)
+ )
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -225,7 +229,8 @@ async def async_process(
LOGGER.debug("Prompt: %s", messages)
LOGGER.debug("Tools: %s", tools)
trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages}
+ trace.ConversationTraceEventType.AGENT_DETAIL,
+ {"messages": messages, "tools": llm_api.tools if llm_api else None},
)
client = self.entry.runtime_data
@@ -318,3 +323,10 @@ def message_convert(
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
+
+ async def _async_entry_update_listener(
+ self, hass: HomeAssistant, entry: ConfigEntry
+ ) -> None:
+ """Handle options update."""
+ # Reload as we update device info + entity name + supported features
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py
index 7aea6aafe20548..747b93179bcc3f 100644
--- a/homeassistant/components/openweathermap/__init__.py
+++ b/homeassistant/components/openweathermap/__init__.py
@@ -5,7 +5,7 @@
from dataclasses import dataclass
import logging
-from pyopenweathermap import OWMClient
+from pyopenweathermap import create_owm_client
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -33,6 +33,7 @@ class OpenweathermapData:
"""Runtime data definition."""
name: str
+ mode: str
coordinator: WeatherUpdateCoordinator
@@ -52,7 +53,7 @@ async def async_setup_entry(
else:
async_delete_issue(hass, entry.entry_id)
- owm_client = OWMClient(api_key, mode, lang=language)
+ owm_client = create_owm_client(api_key, mode, lang=language)
weather_coordinator = WeatherUpdateCoordinator(
owm_client, latitude, longitude, hass
)
@@ -61,7 +62,7 @@ async def async_setup_entry(
entry.async_on_unload(entry.add_update_listener(async_update_options))
- entry.runtime_data = OpenweathermapData(name, weather_coordinator)
+ entry.runtime_data = OpenweathermapData(name, mode, weather_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py
index 6c9997fc061549..d34125a240595a 100644
--- a/homeassistant/components/openweathermap/const.py
+++ b/homeassistant/components/openweathermap/const.py
@@ -58,10 +58,17 @@
FORECAST_MODE_FREE_DAILY = "freedaily"
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
-OWM_MODE_V25 = "v2.5"
+OWM_MODE_FREE_CURRENT = "current"
+OWM_MODE_FREE_FORECAST = "forecast"
OWM_MODE_V30 = "v3.0"
-OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
-DEFAULT_OWM_MODE = OWM_MODE_V30
+OWM_MODE_V25 = "v2.5"
+OWM_MODES = [
+ OWM_MODE_FREE_CURRENT,
+ OWM_MODE_FREE_FORECAST,
+ OWM_MODE_V30,
+ OWM_MODE_V25,
+]
+DEFAULT_OWM_MODE = OWM_MODE_FREE_CURRENT
LANGUAGES = [
"af",
diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py
index 0f99af5ad64abf..f7672a1290bc51 100644
--- a/homeassistant/components/openweathermap/coordinator.py
+++ b/homeassistant/components/openweathermap/coordinator.py
@@ -86,8 +86,14 @@ def _convert_weather_response(self, weather_report: WeatherReport):
"""Format the weather response correctly."""
_LOGGER.debug("OWM weather response: %s", weather_report)
+ current_weather = (
+ self._get_current_weather_data(weather_report.current)
+ if weather_report.current is not None
+ else {}
+ )
+
return {
- ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current),
+ ATTR_API_CURRENT: current_weather,
ATTR_API_HOURLY_FORECAST: [
self._get_hourly_forecast_weather_data(item)
for item in weather_report.hourly_forecast
@@ -122,6 +128,8 @@ def _get_current_weather_data(self, current_weather: CurrentWeather):
}
def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
+ uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
+
return Forecast(
datetime=forecast.date_time.isoformat(),
condition=self._get_condition(forecast.condition.id),
@@ -134,12 +142,14 @@ def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
wind_speed=forecast.wind_speed,
native_wind_gust_speed=forecast.wind_gust,
wind_bearing=forecast.wind_bearing,
- uv_index=float(forecast.uv_index),
+ uv_index=uv_index,
precipitation_probability=round(forecast.precipitation_probability * 100),
precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
)
def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
+ uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
+
return Forecast(
datetime=forecast.date_time.isoformat(),
condition=self._get_condition(forecast.condition.id),
@@ -153,7 +163,7 @@ def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
wind_speed=forecast.wind_speed,
native_wind_gust_speed=forecast.wind_gust,
wind_bearing=forecast.wind_bearing,
- uv_index=float(forecast.uv_index),
+ uv_index=uv_index,
precipitation_probability=round(forecast.precipitation_probability * 100),
precipitation=round(forecast.rain + forecast.snow, 2),
)
diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json
index e2c809cf38571d..199e750ad4f3fa 100644
--- a/homeassistant/components/openweathermap/manifest.json
+++ b/homeassistant/components/openweathermap/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
"iot_class": "cloud_polling",
"loggers": ["pyopenweathermap"],
- "requirements": ["pyopenweathermap==0.0.9"]
+ "requirements": ["pyopenweathermap==0.1.1"]
}
diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py
index 89905e99ed99ac..46789f4b3d22cb 100644
--- a/homeassistant/components/openweathermap/sensor.py
+++ b/homeassistant/components/openweathermap/sensor.py
@@ -19,6 +19,7 @@
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -47,6 +48,7 @@
DEFAULT_NAME,
DOMAIN,
MANUFACTURER,
+ OWM_MODE_FREE_FORECAST,
)
from .coordinator import WeatherUpdateCoordinator
@@ -161,16 +163,23 @@ async def async_setup_entry(
name = domain_data.name
weather_coordinator = domain_data.coordinator
- entities: list[AbstractOpenWeatherMapSensor] = [
- OpenWeatherMapSensor(
- name,
- f"{config_entry.unique_id}-{description.key}",
- description,
- weather_coordinator,
+ if domain_data.mode == OWM_MODE_FREE_FORECAST:
+ entity_registry = er.async_get(hass)
+ entries = er.async_entries_for_config_entry(
+ entity_registry, config_entry.entry_id
+ )
+ for entry in entries:
+ entity_registry.async_remove(entry.entity_id)
+ else:
+ async_add_entities(
+ OpenWeatherMapSensor(
+ name,
+ f"{config_entry.unique_id}-{description.key}",
+ description,
+ weather_coordinator,
+ )
+ for description in WEATHER_SENSOR_TYPES
)
- for description in WEATHER_SENSOR_TYPES
- ]
- async_add_entities(entities)
class AbstractOpenWeatherMapSensor(SensorEntity):
diff --git a/homeassistant/components/openweathermap/utils.py b/homeassistant/components/openweathermap/utils.py
index 7f2391b21a1eb9..ba5378fb31cea2 100644
--- a/homeassistant/components/openweathermap/utils.py
+++ b/homeassistant/components/openweathermap/utils.py
@@ -2,7 +2,7 @@
from typing import Any
-from pyopenweathermap import OWMClient, RequestError
+from pyopenweathermap import RequestError, create_owm_client
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
@@ -16,7 +16,7 @@ async def validate_api_key(api_key, mode):
api_key_valid = None
errors, description_placeholders = {}, {}
try:
- owm_client = OWMClient(api_key, mode)
+ owm_client = create_owm_client(api_key, mode)
api_key_valid = await owm_client.validate_key()
except RequestError as error:
errors["base"] = "cannot_connect"
diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py
index 62b1521823337e..3a134a0ee26679 100644
--- a/homeassistant/components/openweathermap/weather.py
+++ b/homeassistant/components/openweathermap/weather.py
@@ -8,6 +8,7 @@
WeatherEntityFeature,
)
from homeassistant.const import (
+ UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
@@ -29,6 +30,7 @@
ATTR_API_HUMIDITY,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
+ ATTR_API_VISIBILITY_DISTANCE,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_GUST,
ATTR_API_WIND_SPEED,
@@ -36,6 +38,9 @@
DEFAULT_NAME,
DOMAIN,
MANUFACTURER,
+ OWM_MODE_FREE_FORECAST,
+ OWM_MODE_V25,
+ OWM_MODE_V30,
)
from .coordinator import WeatherUpdateCoordinator
@@ -48,10 +53,11 @@ async def async_setup_entry(
"""Set up OpenWeatherMap weather entity based on a config entry."""
domain_data = config_entry.runtime_data
name = domain_data.name
+ mode = domain_data.mode
weather_coordinator = domain_data.coordinator
unique_id = f"{config_entry.unique_id}"
- owm_weather = OpenWeatherMapWeather(name, unique_id, weather_coordinator)
+ owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator)
async_add_entities([owm_weather], False)
@@ -66,11 +72,13 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
+ _attr_native_visibility_unit = UnitOfLength.METERS
def __init__(
self,
name: str,
unique_id: str,
+ mode: str,
weather_coordinator: WeatherUpdateCoordinator,
) -> None:
"""Initialize the sensor."""
@@ -83,59 +91,71 @@ def __init__(
manufacturer=MANUFACTURER,
name=DEFAULT_NAME,
)
- self._attr_supported_features = (
- WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
- )
+
+ if mode in (OWM_MODE_V30, OWM_MODE_V25):
+ self._attr_supported_features = (
+ WeatherEntityFeature.FORECAST_DAILY
+ | WeatherEntityFeature.FORECAST_HOURLY
+ )
+ elif mode == OWM_MODE_FREE_FORECAST:
+ self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
@property
def condition(self) -> str | None:
"""Return the current condition."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CONDITION)
@property
def cloud_coverage(self) -> float | None:
"""Return the Cloud coverage in %."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CLOUDS)
@property
def native_apparent_temperature(self) -> float | None:
"""Return the apparent temperature."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE]
+ return self.coordinator.data[ATTR_API_CURRENT].get(
+ ATTR_API_FEELS_LIKE_TEMPERATURE
+ )
@property
def native_temperature(self) -> float | None:
"""Return the temperature."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_TEMPERATURE)
@property
def native_pressure(self) -> float | None:
"""Return the pressure."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_PRESSURE)
@property
def humidity(self) -> float | None:
"""Return the humidity."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_HUMIDITY)
@property
def native_dew_point(self) -> float | None:
"""Return the dew point."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_DEW_POINT)
@property
def native_wind_gust_speed(self) -> float | None:
"""Return the wind gust speed."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_GUST)
@property
def native_wind_speed(self) -> float | None:
"""Return the wind speed."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_SPEED)
@property
def wind_bearing(self) -> float | str | None:
"""Return the wind bearing."""
- return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING]
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
+
+ @property
+ def visibility(self) -> float | str | None:
+ """Return visibility."""
+ return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py
index df997f7a68e1ce..ac864686432e73 100644
--- a/homeassistant/components/overkiz/climate_entities/__init__.py
+++ b/homeassistant/components/overkiz/climate_entities/__init__.py
@@ -11,6 +11,9 @@
)
from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer
from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation
+from .atlantic_pass_apc_heat_pump_main_component import (
+ AtlanticPassAPCHeatPumpMainComponent,
+)
from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone
from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl
from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone
@@ -43,6 +46,7 @@ class Controllable(StrEnum):
UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface,
UIWidget.SOMFY_THERMOSTAT: SomfyThermostat,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface,
+ UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent,
}
# For Atlantic APC, some devices are standalone and control themselves, some others needs to be
diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heat_pump_main_component.py
new file mode 100644
index 00000000000000..1cd13205b13028
--- /dev/null
+++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heat_pump_main_component.py
@@ -0,0 +1,65 @@
+"""Support for Atlantic Pass APC Heat Pump Main Component."""
+
+from __future__ import annotations
+
+from asyncio import sleep
+from typing import cast
+
+from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
+
+from homeassistant.components.climate import (
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.const import UnitOfTemperature
+
+from ..const import DOMAIN
+from ..entity import OverkizEntity
+
+OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = {
+ OverkizCommandParam.STOP: HVACMode.OFF,
+ OverkizCommandParam.HEATING: HVACMode.HEAT,
+ OverkizCommandParam.COOLING: HVACMode.COOL,
+}
+
+HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()}
+
+
+class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity):
+ """Representation of Atlantic Pass APC Heat Pump Main Component.
+
+ This component can only turn off the heating pump and select the working mode: heating or cooling.
+ To set new temperatures, they must be selected individually per Zones (ie: AtlanticPassAPCHeatingAndCoolingZone).
+ Once the Device is switched on into heating or cooling mode, the Heat Pump will be activated and will use
+ the default temperature configuration for each available zone.
+ """
+
+ _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ]
+ _attr_supported_features = (
+ ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
+ )
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_translation_key = DOMAIN
+ _enable_turn_on_off_backwards_compatibility = False
+
+ @property
+ def hvac_mode(self) -> HVACMode:
+ """Return hvac current mode: stop, cooling, heating."""
+ return OVERKIZ_TO_HVAC_MODES[
+ cast(
+ str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE)
+ )
+ ]
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set new target hvac mode: stop, cooling, heating."""
+ # They are mainly managed by the Zone Control device
+ # However, we can turn off or put the heat pump in cooling/ heating mode.
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_PASS_APC_OPERATING_MODE,
+ HVAC_MODES_TO_OVERKIZ[hvac_mode],
+ )
+
+ # Wait for 2 seconds to ensure the HVAC mode change is properly applied and system stabilizes.
+ await sleep(2)
diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py
index f18edd0cfe6be7..9027dcf8d03e79 100644
--- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py
+++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py
@@ -234,7 +234,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if self.is_using_derogated_temperature_fallback:
- return await super().async_set_hvac_mode(hvac_mode)
+ await super().async_set_hvac_mode(hvac_mode)
+ return
# They are mainly managed by the Zone Control device
# However, it make sense to map the OFF Mode to the Overkiz STOP Preset
@@ -287,7 +288,8 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self.is_using_derogated_temperature_fallback:
- return await super().async_set_preset_mode(preset_mode)
+ await super().async_set_preset_mode(preset_mode)
+ return
mode = PRESET_MODES_TO_OVERKIZ[preset_mode]
@@ -361,7 +363,8 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new temperature."""
if self.is_using_derogated_temperature_fallback:
- return await super().async_set_temperature(**kwargs)
+ await super().async_set_temperature(**kwargs)
+ return
target_temperature = kwargs.get(ATTR_TEMPERATURE)
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py
index 85ce7ae57e3cd3..acc761664ec20a 100644
--- a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py
+++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py
@@ -181,6 +181,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
) and mode.value_as_str:
- return await self.executor.async_execute_command(
+ await self.executor.async_execute_command(
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
)
+ return
diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py
index 59acc4ac232e6f..a90260e0f0f7f8 100644
--- a/homeassistant/components/overkiz/const.py
+++ b/homeassistant/components/overkiz/const.py
@@ -95,6 +95,7 @@
UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported)
+ UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported)
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index 8825c09e0ffccb..19850f0b57ec8f 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -6,7 +6,8 @@
"@vlebourl",
"@tetienne",
"@nyroDev",
- "@tronix117"
+ "@tronix117",
+ "@alexfp14"
],
"config_flow": true,
"dhcp": [
diff --git a/homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py b/homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py
index 9f0a8798233888..dc2a93a8d2f64f 100644
--- a/homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py
+++ b/homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py
@@ -87,9 +87,10 @@ async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
# Turn water heater off
if operation_mode == OverkizCommandParam.OFF:
- return await self.executor.async_execute_command(
+ await self.executor.async_execute_command(
OverkizCommand.SET_CONTROL_DHW, OverkizCommandParam.STOP
)
+ return
# Turn water heater on, when off
if self.current_operation == OverkizCommandParam.OFF:
diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py
index ad36b664994bd8..bf314e96deca2c 100644
--- a/homeassistant/components/pi_hole/__init__.py
+++ b/homeassistant/components/pi_hole/__init__.py
@@ -20,7 +20,7 @@
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import (
@@ -33,7 +33,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [
Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py
index db78d3ab0a59a3..c1a435f628c36d 100644
--- a/homeassistant/components/pi_hole/update.py
+++ b/homeassistant/components/pi_hole/update.py
@@ -22,6 +22,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription):
installed_version: Callable[[dict], str | None] = lambda api: None
latest_version: Callable[[dict], str | None] = lambda api: None
+ has_update: Callable[[dict], bool | None] = lambda api: None
release_base_url: str | None = None
title: str | None = None
@@ -34,6 +35,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription):
entity_category=EntityCategory.DIAGNOSTIC,
installed_version=lambda versions: versions.get("core_current"),
latest_version=lambda versions: versions.get("core_latest"),
+ has_update=lambda versions: versions.get("core_update"),
release_base_url="https://github.com/pi-hole/pi-hole/releases/tag",
),
PiHoleUpdateEntityDescription(
@@ -43,6 +45,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription):
entity_category=EntityCategory.DIAGNOSTIC,
installed_version=lambda versions: versions.get("web_current"),
latest_version=lambda versions: versions.get("web_latest"),
+ has_update=lambda versions: versions.get("web_update"),
release_base_url="https://github.com/pi-hole/AdminLTE/releases/tag",
),
PiHoleUpdateEntityDescription(
@@ -52,6 +55,7 @@ class PiHoleUpdateEntityDescription(UpdateEntityDescription):
entity_category=EntityCategory.DIAGNOSTIC,
installed_version=lambda versions: versions.get("FTL_current"),
latest_version=lambda versions: versions.get("FTL_latest"),
+ has_update=lambda versions: versions.get("FTL_update"),
release_base_url="https://github.com/pi-hole/FTL/releases/tag",
),
)
@@ -110,7 +114,9 @@ def installed_version(self) -> str | None:
def latest_version(self) -> str | None:
"""Latest version available for install."""
if isinstance(self.api.versions, dict):
- return self.entity_description.latest_version(self.api.versions)
+ if self.entity_description.has_update(self.api.versions):
+ return self.entity_description.latest_version(self.api.versions)
+ return self.installed_version
return None
@property
diff --git a/homeassistant/components/pinecil/__init__.py b/homeassistant/components/pinecil/__init__.py
new file mode 100644
index 00000000000000..a0e847254355d9
--- /dev/null
+++ b/homeassistant/components/pinecil/__init__.py
@@ -0,0 +1 @@
+"""Pinecil integration."""
diff --git a/homeassistant/components/pinecil/manifest.json b/homeassistant/components/pinecil/manifest.json
new file mode 100644
index 00000000000000..4ec6e75cfcb99a
--- /dev/null
+++ b/homeassistant/components/pinecil/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "pinecil",
+ "name": "Pinecil",
+ "integration_type": "virtual",
+ "supported_by": "iron_os"
+}
diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py
index d4c4622a998758..7ab8367bd1d345 100644
--- a/homeassistant/components/plaato/entity.py
+++ b/homeassistant/components/plaato/entity.py
@@ -1,5 +1,7 @@
"""PlaatoEntity class."""
+from typing import Any
+
from pyplaato.models.device import PlaatoDevice
from homeassistant.helpers import entity
@@ -59,7 +61,7 @@ def _sensor_data(self) -> PlaatoDevice:
return self._entry_data[SENSOR_DATA]
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the monitored installation."""
if self._attributes:
return {
@@ -68,6 +70,7 @@ def extra_state_attributes(self):
if plaato_key in self._attributes
and self._attributes[plaato_key] is not None
}
+ return None
@property
def available(self):
diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py
index b549dee2887178..2a5253d3faa389 100644
--- a/homeassistant/components/plant/__init__.py
+++ b/homeassistant/components/plant/__init__.py
@@ -268,6 +268,7 @@ def _check_min(self, sensor_name, value, params):
min_value = self._config[params["min"]]
if value < min_value:
return f"{sensor_name} low"
+ return None
def _check_max(self, sensor_name, value, params):
"""If configured, check the value against the defined maximum value."""
diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py
index e47e614576133f..87e9f47af66471 100644
--- a/homeassistant/components/plex/media_browser.py
+++ b/homeassistant/components/plex/media_browser.py
@@ -132,7 +132,11 @@ def playlists_payload():
"children": [],
}
for playlist in plex_server.playlists():
- if playlist.playlistType != "audio" and platform == "sonos":
+ if (
+ playlist.type != "directory"
+ and playlist.playlistType != "audio"
+ and platform == "sonos"
+ ):
continue
try:
playlists_info["children"].append(item_payload(playlist))
diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py
index be09c729237d23..0b6f889b90a40a 100644
--- a/homeassistant/components/powerwall/__init__.py
+++ b/homeassistant/components/powerwall/__init__.py
@@ -22,7 +22,6 @@
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.network import is_ip_address
@@ -34,8 +33,6 @@
PowerwallRuntimeData,
)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py
index 7113ab6ba7004c..9423d65b0fc468 100644
--- a/homeassistant/components/powerwall/sensor.py
+++ b/homeassistant/components/powerwall/sensor.py
@@ -4,6 +4,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from operator import attrgetter, methodcaller
from typing import TYPE_CHECKING, Generic, TypeVar
from tesla_powerwall import GridState, MeterResponse, MeterType
@@ -58,11 +59,6 @@ def _get_meter_frequency(meter: MeterResponse) -> float:
return round(meter.frequency, 1)
-def _get_meter_total_current(meter: MeterResponse) -> float:
- """Get the current value in A."""
- return meter.get_instant_total_current()
-
-
def _get_meter_average_voltage(meter: MeterResponse) -> float:
"""Get the current value in V."""
return round(meter.instant_average_voltage, 1)
@@ -93,7 +89,7 @@ def _get_meter_average_voltage(meter: MeterResponse) -> float:
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
- value_fn=_get_meter_total_current,
+ value_fn=methodcaller("get_instant_total_current"),
),
PowerwallSensorEntityDescription[MeterResponse, float](
key="instant_voltage",
@@ -132,7 +128,7 @@ def _get_instant_current(battery: BatteryResponse) -> float | None:
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
- value_fn=lambda battery_data: battery_data.capacity,
+ value_fn=attrgetter("capacity"),
),
PowerwallSensorEntityDescription[BatteryResponse, float | None](
key="battery_instant_voltage",
@@ -170,7 +166,7 @@ def _get_instant_current(battery: BatteryResponse) -> float | None:
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
- value_fn=lambda battery_data: battery_data.p_out,
+ value_fn=attrgetter("p_out"),
),
PowerwallSensorEntityDescription[BatteryResponse, float | None](
key="battery_export",
@@ -181,7 +177,7 @@ def _get_instant_current(battery: BatteryResponse) -> float | None:
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=0,
- value_fn=lambda battery_data: battery_data.energy_discharged,
+ value_fn=attrgetter("energy_discharged"),
),
PowerwallSensorEntityDescription[BatteryResponse, float | None](
key="battery_import",
@@ -192,7 +188,7 @@ def _get_instant_current(battery: BatteryResponse) -> float | None:
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=0,
- value_fn=lambda battery_data: battery_data.energy_charged,
+ value_fn=attrgetter("energy_charged"),
),
PowerwallSensorEntityDescription[BatteryResponse, int](
key="battery_remaining",
@@ -203,7 +199,7 @@ def _get_instant_current(battery: BatteryResponse) -> float | None:
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
- value_fn=lambda battery_data: battery_data.energy_remaining,
+ value_fn=attrgetter("energy_remaining"),
),
PowerwallSensorEntityDescription[BatteryResponse, str](
key="grid_state",
diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json
index bb29e2cf105d80..8b072361d34099 100644
--- a/homeassistant/components/private_ble_device/manifest.json
+++ b/homeassistant/components/private_ble_device/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.19.3"]
+ "requirements": ["bluetooth-data-tools==1.19.4"]
}
diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py
index fd911fa5898b11..2df6ff62038e02 100644
--- a/homeassistant/components/prosegur/camera.py
+++ b/homeassistant/components/prosegur/camera.py
@@ -31,7 +31,7 @@ async def async_setup_entry(
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_REQUEST_IMAGE,
- {},
+ None,
"async_request_image",
)
diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py
index 813686789a29d0..763274243c572b 100644
--- a/homeassistant/components/proximity/__init__.py
+++ b/homeassistant/components/proximity/__init__.py
@@ -3,137 +3,20 @@
from __future__ import annotations
import logging
-from typing import cast
-
-import voluptuous as vol
-
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- CONF_DEVICES,
- CONF_NAME,
- CONF_UNIT_OF_MEASUREMENT,
- CONF_ZONE,
- STATE_UNKNOWN,
- Platform,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
-import homeassistant.helpers.config_validation as cv
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import (
async_track_entity_registry_updated_event,
async_track_state_change_event,
)
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-
-from .const import (
- ATTR_DIR_OF_TRAVEL,
- ATTR_DIST_TO,
- ATTR_NEAREST,
- CONF_IGNORED_ZONES,
- CONF_TOLERANCE,
- CONF_TRACKED_ENTITIES,
- DEFAULT_PROXIMITY_ZONE,
- DEFAULT_TOLERANCE,
- DOMAIN,
- UNITS,
-)
+
+from .const import CONF_TRACKED_ENTITIES
from .coordinator import ProximityConfigEntry, ProximityDataUpdateCoordinator
-from .helpers import entity_used_in
_LOGGER = logging.getLogger(__name__)
-ZONE_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string,
- vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.entity_id]),
- vol.Optional(CONF_IGNORED_ZONES, default=[]): vol.All(
- cv.ensure_list, [cv.string]
- ),
- vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): cv.positive_int,
- vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.string, vol.In(UNITS)),
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)},
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def _async_setup_legacy(
- hass: HomeAssistant,
- entry: ProximityConfigEntry,
- coordinator: ProximityDataUpdateCoordinator,
-) -> None:
- """Legacy proximity entity handling, can be removed in 2024.8."""
- friendly_name = entry.data[CONF_NAME]
- proximity = Proximity(hass, friendly_name, coordinator)
- await proximity.async_added_to_hass()
- proximity.async_write_ha_state()
-
- if used_in := entity_used_in(hass, f"{DOMAIN}.{friendly_name}"):
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_proximity_entity_{friendly_name}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=True,
- is_persistent=True,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_proximity_entity",
- translation_placeholders={
- "entity": f"{DOMAIN}.{friendly_name}",
- "used_in": "\n- ".join([f"`{x}`" for x in used_in]),
- },
- )
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Get the zones and offsets from configuration.yaml."""
- if DOMAIN in config:
- for friendly_name, proximity_config in config[DOMAIN].items():
- _LOGGER.debug("import %s with config:%s", friendly_name, proximity_config)
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- CONF_NAME: friendly_name,
- CONF_ZONE: f"zone.{proximity_config[CONF_ZONE]}",
- CONF_TRACKED_ENTITIES: proximity_config[CONF_DEVICES],
- CONF_IGNORED_ZONES: [
- f"zone.{zone}"
- for zone in proximity_config[CONF_IGNORED_ZONES]
- ],
- CONF_TOLERANCE: proximity_config[CONF_TOLERANCE],
- CONF_UNIT_OF_MEASUREMENT: proximity_config.get(
- CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit
- ),
- },
- )
- )
-
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Proximity",
- },
- )
-
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool:
"""Set up Proximity from a config entry."""
@@ -160,9 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) ->
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
- if entry.source == SOURCE_IMPORT:
- await _async_setup_legacy(hass, entry, coordinator)
-
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
@@ -176,45 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
-
-
-class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]):
- """Representation of a Proximity."""
-
- # This entity is legacy and does not have a platform.
- # We can't fix this easily without breaking changes.
- _no_platform_reported = True
-
- def __init__(
- self,
- hass: HomeAssistant,
- friendly_name: str,
- coordinator: ProximityDataUpdateCoordinator,
- ) -> None:
- """Initialize the proximity."""
- super().__init__(coordinator)
- self.hass = hass
- self.entity_id = f"{DOMAIN}.{friendly_name}"
-
- self._attr_name = friendly_name
- self._attr_unit_of_measurement = self.coordinator.unit_of_measurement
-
- @property
- def data(self) -> dict[str, str | int | None]:
- """Get data from coordinator."""
- return self.coordinator.data.proximity
-
- @property
- def state(self) -> str | float:
- """Return the state."""
- if isinstance(distance := self.data[ATTR_DIST_TO], str):
- return distance
- return self.coordinator.convert_legacy(cast(int, distance))
-
- @property
- def extra_state_attributes(self) -> dict[str, str]:
- """Return the state attributes."""
- return {
- ATTR_DIR_OF_TRAVEL: str(self.data[ATTR_DIR_OF_TRAVEL] or STATE_UNKNOWN),
- ATTR_NEAREST: str(self.data[ATTR_NEAREST]),
- }
diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py
index 2d32926832a29d..a8dd85c1523011 100644
--- a/homeassistant/components/proximity/coordinator.py
+++ b/homeassistant/components/proximity/coordinator.py
@@ -13,7 +13,6 @@
ATTR_NAME,
CONF_UNIT_OF_MEASUREMENT,
CONF_ZONE,
- UnitOfLength,
)
from homeassistant.core import (
Event,
@@ -27,7 +26,6 @@
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.location import distance
-from homeassistant.util.unit_conversion import DistanceConverter
from .const import (
ATTR_DIR_OF_TRAVEL,
@@ -145,18 +143,6 @@ async def async_check_tracked_entity_change(
},
)
- def convert_legacy(self, value: float | str) -> float | str:
- """Round and convert given distance value."""
- if isinstance(value, str):
- return value
- return round(
- DistanceConverter.convert(
- value,
- UnitOfLength.METERS,
- self.unit_of_measurement,
- )
- )
-
def _calc_distance_to_zone(
self,
zone: State,
diff --git a/homeassistant/components/proximity/helpers.py b/homeassistant/components/proximity/helpers.py
deleted file mode 100644
index af3d6d2a3bbd39..00000000000000
--- a/homeassistant/components/proximity/helpers.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Helper functions for proximity."""
-
-from homeassistant.components.automation import automations_with_entity
-from homeassistant.components.script import scripts_with_entity
-from homeassistant.core import HomeAssistant
-
-
-def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
- """Get list of related automations and scripts."""
- used_in = automations_with_entity(hass, entity_id)
- used_in += scripts_with_entity(hass, entity_id)
- return used_in
diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json
index 72c95eeeeae42d..118004e908ecd7 100644
--- a/homeassistant/components/proximity/strings.json
+++ b/homeassistant/components/proximity/strings.json
@@ -55,17 +55,6 @@
}
},
"issues": {
- "deprecated_proximity_entity": {
- "title": "The proximity entity is deprecated",
- "fix_flow": {
- "step": {
- "confirm": {
- "title": "[%key:component::proximity::issues::deprecated_proximity_entity::title%]",
- "description": "The proximity entity `{entity}` is deprecated and will be removed in `2024.8`. However it is used within the following configurations:\n- {used_in}\n\nPlease adjust any automations or scripts that use this deprecated Proximity entity.\nFor each tracked person or device one sensor for the distance and the direction of travel to/from the monitored zone is created. Additionally for each Proximity configuration one sensor which shows the nearest device or person to the monitored zone is created. With this you can use the Min/Max integration to determine the nearest and furthest distance."
- }
- }
- }
- },
"tracked_entity_removed": {
"title": "Tracked entity has been removed",
"fix_flow": {
diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py
index a92f159d1721af..6327164e3c8855 100644
--- a/homeassistant/components/pvpc_hourly_pricing/__init__.py
+++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py
@@ -3,7 +3,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
import homeassistant.helpers.entity_registry as er
from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN
@@ -11,7 +10,6 @@
from .helpers import get_enabled_sensor_keys
PLATFORMS: list[Platform] = [Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json
index 38e17e5016fcce..bbe6989f5e7883 100644
--- a/homeassistant/components/pyload/strings.json
+++ b/homeassistant/components/pyload/strings.json
@@ -74,7 +74,7 @@
"name": "Downloads in queue"
},
"total": {
- "name": "Finished downloads"
+ "name": "Total downloads"
},
"free_space": {
"name": "Free space"
diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py
index 72e2f3a824bd10..70e9c5b0d29a78 100644
--- a/homeassistant/components/python_script/__init__.py
+++ b/homeassistant/components/python_script/__init__.py
@@ -108,13 +108,13 @@ def reload_scripts_handler(call: ServiceCall) -> None:
return True
-def discover_scripts(hass):
+def discover_scripts(hass: HomeAssistant) -> None:
"""Discover python scripts in folder."""
path = hass.config.path(FOLDER)
if not os.path.isdir(path):
_LOGGER.warning("Folder %s not found in configuration folder", FOLDER)
- return False
+ return
def python_script_service_handler(call: ServiceCall) -> ServiceResponse:
"""Handle python script service calls."""
@@ -277,7 +277,7 @@ def protected_getattr(obj, name, default=None):
if not isinstance(restricted_globals["output"], dict):
output_type = type(restricted_globals["output"])
restricted_globals["output"] = {}
- raise ScriptError(
+ raise ScriptError( # noqa: TRY301
f"Expected `output` to be a dictionary, was {output_type}"
)
except ScriptError as err:
diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py
index e1739a900ce5e8..526516bfcdda3a 100644
--- a/homeassistant/components/qnap/sensor.py
+++ b/homeassistant/components/qnap/sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import timedelta
+from typing import Any
from homeassistant import config_entries
from homeassistant.components.sensor import (
@@ -348,6 +349,8 @@ def native_value(self):
if self.entity_description.key == "cpu_usage":
return self.coordinator.data["system_stats"]["cpu"]["usage_percent"]
+ return None
+
class QNAPMemorySensor(QNAPSensor):
"""A QNAP sensor that monitors memory stats."""
@@ -370,20 +373,25 @@ def native_value(self):
if self.entity_description.key == "memory_percent_used":
return used / total * 100
+ return None
+
# Deprecated since Home Assistant 2024.6.0
# Can be removed completely in 2024.12.0
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.coordinator.data:
data = self.coordinator.data["system_stats"]["memory"]
size = round(float(data["total"]) / 1024, 2)
return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"}
+ return None
class QNAPNetworkSensor(QNAPSensor):
"""A QNAP sensor that monitors network stats."""
+ monitor_device: str
+
@property
def native_value(self):
"""Return the state of the sensor."""
@@ -404,10 +412,12 @@ def native_value(self):
if self.entity_description.key == "network_rx":
return data["rx"]
+ return None
+
# Deprecated since Home Assistant 2024.6.0
# Can be removed completely in 2024.12.0
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.coordinator.data:
data = self.coordinator.data["system_stats"]["nics"][self.monitor_device]
@@ -418,6 +428,7 @@ def extra_state_attributes(self):
ATTR_MAX_SPEED: data["max_speed"],
ATTR_PACKETS_ERR: data["err_packets"],
}
+ return None
class QNAPSystemSensor(QNAPSensor):
@@ -442,10 +453,12 @@ def native_value(self):
)
return dt_util.now() - uptime_duration
+ return None
+
# Deprecated since Home Assistant 2024.6.0
# Can be removed completely in 2024.12.0
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.coordinator.data:
data = self.coordinator.data["system_stats"]
@@ -459,11 +472,14 @@ def extra_state_attributes(self):
ATTR_SERIAL: data["system"]["serial_number"],
ATTR_UPTIME: f"{days:0>2d}d {hours:0>2d}h {minutes:0>2d}m",
}
+ return None
class QNAPDriveSensor(QNAPSensor):
"""A QNAP sensor that monitors HDD/SSD drive stats."""
+ monitor_device: str
+
@property
def native_value(self):
"""Return the state of the sensor."""
@@ -475,8 +491,10 @@ def native_value(self):
if self.entity_description.key == "drive_temp":
return int(data["temp_c"]) if data["temp_c"] is not None else 0
+ return None
+
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.coordinator.data:
data = self.coordinator.data["smart_drive_health"][self.monitor_device]
@@ -486,11 +504,14 @@ def extra_state_attributes(self):
ATTR_SERIAL: data["serial"],
ATTR_TYPE: data["type"],
}
+ return None
class QNAPVolumeSensor(QNAPSensor):
"""A QNAP sensor that monitors storage volume stats."""
+ monitor_device: str
+
@property
def native_value(self):
"""Return the state of the sensor."""
@@ -511,10 +532,12 @@ def native_value(self):
if self.entity_description.key == "volume_percentage_used":
return used_gb / total_gb * 100
+ return None
+
# Deprecated since Home Assistant 2024.6.0
# Can be removed completely in 2024.12.0
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.coordinator.data:
data = self.coordinator.data["volumes"][self.monitor_device]
@@ -523,3 +546,4 @@ def extra_state_attributes(self):
return {
ATTR_VOLUME_SIZE: f"{round(total_gb, 1)} {UnitOfInformation.GIBIBYTES}"
}
+ return None
diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json
index b8c6213319390d..d34848346b79d9 100644
--- a/homeassistant/components/qnap_qsw/manifest.json
+++ b/homeassistant/components/qnap_qsw/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
- "requirements": ["aioqsw==0.4.0"]
+ "requirements": ["aioqsw==0.4.1"]
}
diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py
index a5922e0cb9504c..6976d3f5ba68c5 100644
--- a/homeassistant/components/rachio/__init__.py
+++ b/homeassistant/components/rachio/__init__.py
@@ -11,7 +11,6 @@
from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN
from .device import RachioPerson
@@ -25,8 +24,6 @@
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py
index e6f5d2ecf8d438..a805024357c790 100644
--- a/homeassistant/components/raincloud/__init__.py
+++ b/homeassistant/components/raincloud/__init__.py
@@ -102,7 +102,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
raincloud = RainCloudy(username=username, password=password)
if not raincloud.is_connected:
- raise HTTPError
+ raise HTTPError # noqa: TRY301
hass.data[DATA_RAINCLOUD] = RainCloudHub(raincloud)
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Rain Cloud service: %s", str(ex))
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
index cfbc95cf0094fb..b10d562ac67b1e 100644
--- a/homeassistant/components/rainmachine/__init__.py
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -58,7 +58,6 @@
DEFAULT_SSL = True
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [
Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py
index d4c0064219e1f3..8368db47d61c8a 100644
--- a/homeassistant/components/rainmachine/switch.py
+++ b/homeassistant/components/rainmachine/switch.py
@@ -6,7 +6,7 @@
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from datetime import datetime
-from typing import Any, Concatenate, cast
+from typing import Any, Concatenate
from regenmaschine.errors import RainMachineError
import voluptuous as vol
@@ -184,8 +184,8 @@ async def async_setup_entry(
"""Set up RainMachine switches based on a config entry."""
platform = entity_platform.async_get_current_platform()
- for service_name, schema, method in (
- ("start_program", {}, "async_start_program"),
+ services: tuple[tuple[str, VolDictType | None, str], ...] = (
+ ("start_program", None, "async_start_program"),
(
"start_zone",
{
@@ -195,11 +195,11 @@ async def async_setup_entry(
},
"async_start_zone",
),
- ("stop_program", {}, "async_stop_program"),
- ("stop_zone", {}, "async_stop_zone"),
- ):
- schema_dict = cast(VolDictType, schema)
- platform.async_register_entity_service(service_name, schema_dict, method)
+ ("stop_program", None, "async_stop_program"),
+ ("stop_zone", None, "async_stop_zone"),
+ )
+ for service_name, schema, method in services:
+ platform.async_register_entity_service(service_name, schema, method)
data = entry.runtime_data
entities: list[RainMachineBaseSwitch] = []
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index 8d4cc29d9bed82..dd293ed6bc2c06 100644
--- a/homeassistant/components/recorder/db_schema.py
+++ b/homeassistant/components/recorder/db_schema.py
@@ -77,7 +77,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
-SCHEMA_VERSION = 44
+SCHEMA_VERSION = 45
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index 2932ea484c9604..185be02e9aad87 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -15,7 +15,6 @@
import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
from sqlalchemy.engine import CursorResult, Engine
-from sqlalchemy.engine.interfaces import ReflectedForeignKeyConstraint
from sqlalchemy.exc import (
DatabaseError,
IntegrityError,
@@ -580,12 +579,24 @@ def _modify_columns(
_LOGGER.exception(
"Could not modify column %s in table %s", column_def, table_name
)
+ raise
def _update_states_table_with_foreign_key_options(
session_maker: Callable[[], Session], engine: Engine
) -> None:
- """Add the options to foreign key constraints."""
+ """Add the options to foreign key constraints.
+
+ This is not supported for SQLite because it does not support
+ dropping constraints.
+ """
+
+ if engine.dialect.name not in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL):
+ raise RuntimeError(
+ "_update_states_table_with_foreign_key_options not supported for "
+ f"{engine.dialect.name}"
+ )
+
inspector = sqlalchemy.inspect(engine)
tmp_states_table = Table(TABLE_STATES, MetaData())
alters = [
@@ -596,7 +607,7 @@ def _update_states_table_with_foreign_key_options(
"columns": foreign_key["constrained_columns"],
}
for foreign_key in inspector.get_foreign_keys(TABLE_STATES)
- if foreign_key["name"]
+ if foreign_key["name"] # It's not possible to drop an unnamed constraint
and (
# MySQL/MariaDB will have empty options
not foreign_key.get("options")
@@ -628,20 +639,26 @@ def _update_states_table_with_foreign_key_options(
_LOGGER.exception(
"Could not update foreign options in %s table", TABLE_STATES
)
+ raise
def _drop_foreign_key_constraints(
session_maker: Callable[[], Session], engine: Engine, table: str, column: str
-) -> list[tuple[str, str, ReflectedForeignKeyConstraint]]:
- """Drop foreign key constraints for a table on specific columns."""
+) -> None:
+ """Drop foreign key constraints for a table on specific columns.
+
+ This is not supported for SQLite because it does not support
+ dropping constraints.
+ """
+
+ if engine.dialect.name not in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL):
+ raise RuntimeError(
+ f"_drop_foreign_key_constraints not supported for {engine.dialect.name}"
+ )
+
inspector = sqlalchemy.inspect(engine)
- dropped_constraints = [
- (table, column, foreign_key)
- for foreign_key in inspector.get_foreign_keys(table)
- if foreign_key["name"] and foreign_key["constrained_columns"] == [column]
- ]
- ## Bind the ForeignKeyConstraints to the table
+ ## Find matching named constraints and bind the ForeignKeyConstraints to the table
tmp_table = Table(table, MetaData())
drops = [
ForeignKeyConstraint((), (), name=foreign_key["name"], table=tmp_table)
@@ -660,40 +677,184 @@ def _drop_foreign_key_constraints(
TABLE_STATES,
column,
)
-
- return dropped_constraints
+ raise
def _restore_foreign_key_constraints(
session_maker: Callable[[], Session],
engine: Engine,
- dropped_constraints: list[tuple[str, str, ReflectedForeignKeyConstraint]],
+ foreign_columns: list[tuple[str, str, str | None, str | None]],
) -> None:
"""Restore foreign key constraints."""
- for table, column, dropped_constraint in dropped_constraints:
+ for table, column, foreign_table, foreign_column in foreign_columns:
constraints = Base.metadata.tables[table].foreign_key_constraints
for constraint in constraints:
if constraint.column_keys == [column]:
break
else:
- _LOGGER.info(
- "Did not find a matching constraint for %s", dropped_constraint
- )
+ _LOGGER.info("Did not find a matching constraint for %s.%s", table, column)
continue
+ if TYPE_CHECKING:
+ assert foreign_table is not None
+ assert foreign_column is not None
+
# AddConstraint mutates the constraint passed to it, we need to
# undo that to avoid changing the behavior of the table schema.
# https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748
create_rule = constraint._create_rule # noqa: SLF001
add_constraint = AddConstraint(constraint) # type: ignore[no-untyped-call]
constraint._create_rule = create_rule # noqa: SLF001
+ try:
+ _add_constraint(session_maker, add_constraint, table, column)
+ except IntegrityError:
+ _LOGGER.exception(
+ (
+ "Could not update foreign options in %s table, will delete "
+ "violations and try again"
+ ),
+ table,
+ )
+ _delete_foreign_key_violations(
+ session_maker, engine, table, column, foreign_table, foreign_column
+ )
+ _add_constraint(session_maker, add_constraint, table, column)
- with session_scope(session=session_maker()) as session:
- try:
- connection = session.connection()
- connection.execute(add_constraint)
- except (InternalError, OperationalError):
- _LOGGER.exception("Could not update foreign options in %s table", table)
+
+def _add_constraint(
+ session_maker: Callable[[], Session],
+ add_constraint: AddConstraint,
+ table: str,
+ column: str,
+) -> None:
+ """Add a foreign key constraint."""
+ _LOGGER.warning(
+ "Adding foreign key constraint to %s.%s. "
+ "Note: this can take several minutes on large databases and slow "
+ "machines. Please be patient!",
+ table,
+ column,
+ )
+ with session_scope(session=session_maker()) as session:
+ try:
+ connection = session.connection()
+ connection.execute(add_constraint)
+ except (InternalError, OperationalError):
+ _LOGGER.exception("Could not update foreign options in %s table", table)
+ raise
+
+
+def _delete_foreign_key_violations(
+ session_maker: Callable[[], Session],
+ engine: Engine,
+ table: str,
+ column: str,
+ foreign_table: str,
+ foreign_column: str,
+) -> None:
+ """Remove rows which violate the constraints."""
+ if engine.dialect.name not in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL):
+ raise RuntimeError(
+ f"_delete_foreign_key_violations not supported for {engine.dialect.name}"
+ )
+
+ _LOGGER.warning(
+ "Rows in table %s where %s references non existing %s.%s will be %s. "
+ "Note: this can take several minutes on large databases and slow "
+ "machines. Please be patient!",
+ table,
+ column,
+ foreign_table,
+ foreign_column,
+ "set to NULL" if table == foreign_table else "deleted",
+ )
+
+ result: CursorResult | None = None
+ if table == foreign_table:
+ # In case of a foreign reference to the same table, we set invalid
+ # references to NULL instead of deleting as deleting rows may
+ # cause additional invalid references to be created. This is to handle
+ # old_state_id referencing a missing state.
+ if engine.dialect.name == SupportedDialect.MYSQL:
+ while result is None or result.rowcount > 0:
+ with session_scope(session=session_maker()) as session:
+ # The subquery (SELECT {foreign_column} from {foreign_table}) is
+ # to be compatible with old MySQL versions which do not allow
+ # referencing the table being updated in the WHERE clause.
+ result = session.connection().execute(
+ text(
+ f"UPDATE {table} as t1 " # noqa: S608
+ f"SET {column} = NULL "
+ "WHERE ("
+ f"t1.{column} IS NOT NULL AND "
+ "NOT EXISTS "
+ "(SELECT 1 "
+ f"FROM (SELECT {foreign_column} from {foreign_table}) AS t2 "
+ f"WHERE t2.{foreign_column} = t1.{column})) "
+ "LIMIT 100000;"
+ )
+ )
+ elif engine.dialect.name == SupportedDialect.POSTGRESQL:
+ while result is None or result.rowcount > 0:
+ with session_scope(session=session_maker()) as session:
+ # PostgreSQL does not support LIMIT in UPDATE clauses, so we
+ # update matches from a limited subquery instead.
+ result = session.connection().execute(
+ text(
+ f"UPDATE {table} " # noqa: S608
+ f"SET {column} = NULL "
+ f"WHERE {column} in "
+ f"(SELECT {column} from {table} as t1 "
+ "WHERE ("
+ f"t1.{column} IS NOT NULL AND "
+ "NOT EXISTS "
+ "(SELECT 1 "
+ f"FROM {foreign_table} AS t2 "
+ f"WHERE t2.{foreign_column} = t1.{column})) "
+ "LIMIT 100000);"
+ )
+ )
+ return
+
+ if engine.dialect.name == SupportedDialect.MYSQL:
+ while result is None or result.rowcount > 0:
+ with session_scope(session=session_maker()) as session:
+ result = session.connection().execute(
+ # We don't use an alias for the table we're deleting from,
+ # support of the form `DELETE FROM table AS t1` was added in
+ # MariaDB 11.6 and is not supported by MySQL. MySQL and older
+ # MariaDB instead support the from `DELETE t1 from table AS t1`
+ # which is undocumented for MariaDB.
+ text(
+ f"DELETE FROM {table} " # noqa: S608
+ "WHERE ("
+ f"{table}.{column} IS NOT NULL AND "
+ "NOT EXISTS "
+ "(SELECT 1 "
+ f"FROM {foreign_table} AS t2 "
+ f"WHERE t2.{foreign_column} = {table}.{column})) "
+ "LIMIT 100000;"
+ )
+ )
+ elif engine.dialect.name == SupportedDialect.POSTGRESQL:
+ while result is None or result.rowcount > 0:
+ with session_scope(session=session_maker()) as session:
+ # PostgreSQL does not support LIMIT in DELETE clauses, so we
+ # delete matches from a limited subquery instead.
+ result = session.connection().execute(
+ text(
+ f"DELETE FROM {table} " # noqa: S608
+ f"WHERE {column} in "
+ f"(SELECT {column} from {table} as t1 "
+ "WHERE ("
+ f"t1.{column} IS NOT NULL AND "
+ "NOT EXISTS "
+ "(SELECT 1 "
+ f"FROM {foreign_table} AS t2 "
+ f"WHERE t2.{foreign_column} = t1.{column})) "
+ "LIMIT 100000);"
+ )
+ )
@database_job_retry_wrapper("Apply migration update", 10)
@@ -879,7 +1040,17 @@ class _SchemaVersion11Migrator(_SchemaVersionMigrator, target_version=11):
def _apply_update(self) -> None:
"""Version specific update method."""
_create_index(self.session_maker, "states", "ix_states_old_state_id")
- _update_states_table_with_foreign_key_options(self.session_maker, self.engine)
+
+ # _update_states_table_with_foreign_key_options first drops foreign
+ # key constraints, and then re-adds them with the correct settings.
+ # This is not supported by SQLite
+ if self.engine.dialect.name in (
+ SupportedDialect.MYSQL,
+ SupportedDialect.POSTGRESQL,
+ ):
+ _update_states_table_with_foreign_key_options(
+ self.session_maker, self.engine
+ )
class _SchemaVersion12Migrator(_SchemaVersionMigrator, target_version=12):
@@ -933,9 +1104,18 @@ def _apply_update(self) -> None:
class _SchemaVersion16Migrator(_SchemaVersionMigrator, target_version=16):
def _apply_update(self) -> None:
"""Version specific update method."""
- _drop_foreign_key_constraints(
- self.session_maker, self.engine, TABLE_STATES, "old_state_id"
- )
+ # Dropping foreign key constraints is not supported by SQLite
+ if self.engine.dialect.name in (
+ SupportedDialect.MYSQL,
+ SupportedDialect.POSTGRESQL,
+ ):
+ # Version 16 changes settings for the foreign key constraint on
+ # states.old_state_id. Dropping the constraint is not really correct
+ # we should have recreated it instead. Recreating the constraint now
+ # happens in the migration to schema version 45.
+ _drop_foreign_key_constraints(
+ self.session_maker, self.engine, TABLE_STATES, "old_state_id"
+ )
class _SchemaVersion17Migrator(_SchemaVersionMigrator, target_version=17):
@@ -1457,6 +1637,38 @@ def _apply_update(self) -> None:
)
+FOREIGN_COLUMNS = (
+ (
+ "events",
+ ("data_id", "event_type_id"),
+ (
+ ("data_id", "event_data", "data_id"),
+ ("event_type_id", "event_types", "event_type_id"),
+ ),
+ ),
+ (
+ "states",
+ ("event_id", "old_state_id", "attributes_id", "metadata_id"),
+ (
+ ("event_id", None, None),
+ ("old_state_id", "states", "state_id"),
+ ("attributes_id", "state_attributes", "attributes_id"),
+ ("metadata_id", "states_meta", "metadata_id"),
+ ),
+ ),
+ (
+ "statistics",
+ ("metadata_id",),
+ (("metadata_id", "statistics_meta", "id"),),
+ ),
+ (
+ "statistics_short_term",
+ ("metadata_id",),
+ (("metadata_id", "statistics_meta", "id"),),
+ ),
+)
+
+
class _SchemaVersion44Migrator(_SchemaVersionMigrator, target_version=44):
def _apply_update(self) -> None:
"""Version specific update method."""
@@ -1469,24 +1681,14 @@ def _apply_update(self) -> None:
else ""
)
# First drop foreign key constraints
- foreign_columns = (
- ("events", ("data_id", "event_type_id")),
- ("states", ("event_id", "old_state_id", "attributes_id", "metadata_id")),
- ("statistics", ("metadata_id",)),
- ("statistics_short_term", ("metadata_id",)),
- )
- dropped_constraints = [
- dropped_constraint
- for table, columns in foreign_columns
- for column in columns
- for dropped_constraint in _drop_foreign_key_constraints(
- self.session_maker, self.engine, table, column
- )
- ]
- _LOGGER.debug("Dropped foreign key constraints: %s", dropped_constraints)
+ for table, columns, _ in FOREIGN_COLUMNS:
+ for column in columns:
+ _drop_foreign_key_constraints(
+ self.session_maker, self.engine, table, column
+ )
# Then modify the constrained columns
- for table, columns in foreign_columns:
+ for table, columns, _ in FOREIGN_COLUMNS:
_modify_columns(
self.session_maker,
self.engine,
@@ -1516,9 +1718,24 @@ def _apply_update(self) -> None:
table,
[f"{column} {BIG_INTEGER_SQL} {identity_sql}"],
)
- # Finally restore dropped constraints
+
+
+class _SchemaVersion45Migrator(_SchemaVersionMigrator, target_version=45):
+ def _apply_update(self) -> None:
+ """Version specific update method."""
+ # We skip this step for SQLITE, it doesn't have differently sized integers
+ if self.engine.dialect.name == SupportedDialect.SQLITE:
+ return
+
+ # Restore constraints dropped in migration to schema version 44
_restore_foreign_key_constraints(
- self.session_maker, self.engine, dropped_constraints
+ self.session_maker,
+ self.engine,
+ [
+ (table, column, foreign_table, foreign_column)
+ for table, _, foreign_mappings in FOREIGN_COLUMNS
+ for column, foreign_table, foreign_column in foreign_mappings
+ ],
)
@@ -1956,14 +2173,20 @@ def cleanup_legacy_states_event_ids(instance: Recorder) -> bool:
if instance.dialect_name == SupportedDialect.SQLITE:
# SQLite does not support dropping foreign key constraints
# so we have to rebuild the table
- rebuild_sqlite_table(session_maker, instance.engine, States)
+ fk_remove_ok = rebuild_sqlite_table(session_maker, instance.engine, States)
else:
- _drop_foreign_key_constraints(
- session_maker, instance.engine, TABLE_STATES, "event_id"
- )
- _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
- instance.use_legacy_events_index = False
- _mark_migration_done(session, EventIDPostMigration)
+ try:
+ _drop_foreign_key_constraints(
+ session_maker, instance.engine, TABLE_STATES, "event_id"
+ )
+ except (InternalError, OperationalError):
+ fk_remove_ok = False
+ else:
+ fk_remove_ok = True
+ if fk_remove_ok:
+ _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
+ instance.use_legacy_events_index = False
+ _mark_migration_done(session, EventIDPostMigration)
return True
@@ -2419,6 +2642,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
migration_id = "event_id_post_migration"
task = MigrationTask
+ migration_version = 2
@staticmethod
def migrate_data(instance: Recorder) -> bool:
@@ -2469,7 +2693,7 @@ def _mark_migration_done(
def rebuild_sqlite_table(
session_maker: Callable[[], Session], engine: Engine, table: type[Base]
-) -> None:
+) -> bool:
"""Rebuild an SQLite table.
This must only be called after all migrations are complete
@@ -2524,8 +2748,10 @@ def rebuild_sqlite_table(
# Swallow the exception since we do not want to ever raise
# an integrity error as it would cause the database
# to be discarded and recreated from scratch
+ return False
else:
_LOGGER.warning("Rebuilding SQLite table %s finished", orig_name)
+ return True
finally:
with session_scope(session=session_maker()) as session:
# Step 12 - Re-enable foreign keys
diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py
index dcb19ddf0446ba..30f8fa8d07a47a 100644
--- a/homeassistant/components/recorder/pool.py
+++ b/homeassistant/components/recorder/pool.py
@@ -71,7 +71,8 @@ def recreate(self) -> RecorderPool:
def _do_return_conn(self, record: ConnectionPoolEntry) -> None:
if threading.get_ident() in self.recorder_and_worker_thread_ids:
- return super()._do_return_conn(record)
+ super()._do_return_conn(record)
+ return
record.close()
def shutdown(self) -> None:
@@ -99,7 +100,7 @@ def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return]
# which is allowed but discouraged since its much slower
return self._do_get_db_connection_protected()
# In the event loop, raise an exception
- raise_for_blocking_call(
+ raise_for_blocking_call( # noqa: RET503
self._do_get_db_connection_protected,
strict=True,
advise_msg=ADVISE_MSG,
diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json
index bf5d95ae1fcb75..2ded6be58d6c05 100644
--- a/homeassistant/components/recorder/strings.json
+++ b/homeassistant/components/recorder/strings.json
@@ -1,11 +1,11 @@
{
"system_health": {
"info": {
- "oldest_recorder_run": "Oldest Run Start Time",
- "current_recorder_run": "Current Run Start Time",
- "estimated_db_size": "Estimated Database Size (MiB)",
- "database_engine": "Database Engine",
- "database_version": "Database Version"
+ "oldest_recorder_run": "Oldest run start time",
+ "current_recorder_run": "Current run start time",
+ "estimated_db_size": "Estimated database size (MiB)",
+ "database_engine": "Database engine",
+ "database_version": "Database version"
}
},
"issues": {
@@ -16,6 +16,10 @@
"backup_failed_out_of_resources": {
"title": "Database backup failed due to lack of resources",
"description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter."
+ },
+ "sqlite_too_old": {
+ "title": "Update SQLite to {min_version} or later to continue using the recorder",
+ "description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software."
}
},
"services": {
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
index 1ef85b28f8d3a2..4d494aed7d541a 100644
--- a/homeassistant/components/recorder/util.py
+++ b/homeassistant/components/recorder/util.py
@@ -96,6 +96,7 @@ def _simple_version(version: str) -> AwesomeVersion:
MIN_VERSION_MYSQL = _simple_version("8.0.0")
MIN_VERSION_PGSQL = _simple_version("12.0")
MIN_VERSION_SQLITE = _simple_version("3.31.0")
+UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1")
MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0")
@@ -356,7 +357,7 @@ def _fail_unsupported_dialect(dialect_name: str) -> NoReturn:
raise UnsupportedDialect
-def _fail_unsupported_version(
+def _raise_if_version_unsupported(
server_version: str, dialect_name: str, minimum_version: str
) -> NoReturn:
"""Warn about unsupported database version."""
@@ -373,16 +374,54 @@ def _fail_unsupported_version(
raise UnsupportedDialect
+@callback
+def _async_delete_issue_deprecated_version(
+ hass: HomeAssistant, dialect_name: str
+) -> None:
+ """Delete the issue about upcoming unsupported database version."""
+ ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old")
+
+
+@callback
+def _async_create_issue_deprecated_version(
+ hass: HomeAssistant,
+ server_version: AwesomeVersion,
+ dialect_name: str,
+ min_version: AwesomeVersion,
+) -> None:
+ """Warn about upcoming unsupported database version."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"{dialect_name}_too_old",
+ is_fixable=False,
+ severity=ir.IssueSeverity.CRITICAL,
+ translation_key=f"{dialect_name}_too_old",
+ translation_placeholders={
+ "server_version": str(server_version),
+ "min_version": str(min_version),
+ },
+ breaks_in_ha_version="2025.2.0",
+ )
+
+
+def _extract_version_from_server_response_or_raise(
+ server_response: str,
+) -> AwesomeVersion:
+ """Extract version from server response."""
+ return AwesomeVersion(
+ server_response,
+ ensure_strategy=AwesomeVersionStrategy.SIMPLEVER,
+ find_first_match=True,
+ )
+
+
def _extract_version_from_server_response(
server_response: str,
) -> AwesomeVersion | None:
"""Attempt to extract version from server response."""
try:
- return AwesomeVersion(
- server_response,
- ensure_strategy=AwesomeVersionStrategy.SIMPLEVER,
- find_first_match=True,
- )
+ return _extract_version_from_server_response_or_raise(server_response)
except AwesomeVersionException:
return None
@@ -475,13 +514,27 @@ def setup_connection_for_dialect(
# as its persistent and isn't free to call every time.
result = query_on_connection(dbapi_connection, "SELECT sqlite_version()")
version_string = result[0][0]
- version = _extract_version_from_server_response(version_string)
+ version = _extract_version_from_server_response_or_raise(version_string)
- if not version or version < MIN_VERSION_SQLITE:
- _fail_unsupported_version(
+ if version < MIN_VERSION_SQLITE:
+ _raise_if_version_unsupported(
version or version_string, "SQLite", MIN_VERSION_SQLITE
)
+ # No elif here since _raise_if_version_unsupported raises
+ if version < UPCOMING_MIN_VERSION_SQLITE:
+ instance.hass.add_job(
+ _async_create_issue_deprecated_version,
+ instance.hass,
+ version or version_string,
+ dialect_name,
+ UPCOMING_MIN_VERSION_SQLITE,
+ )
+ else:
+ instance.hass.add_job(
+ _async_delete_issue_deprecated_version, instance.hass, dialect_name
+ )
+
if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS:
max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS
@@ -513,7 +566,7 @@ def setup_connection_for_dialect(
if is_maria_db:
if not version or version < MIN_VERSION_MARIA_DB:
- _fail_unsupported_version(
+ _raise_if_version_unsupported(
version or version_string, "MariaDB", MIN_VERSION_MARIA_DB
)
if version and (
@@ -529,7 +582,7 @@ def setup_connection_for_dialect(
)
elif not version or version < MIN_VERSION_MYSQL:
- _fail_unsupported_version(
+ _raise_if_version_unsupported(
version or version_string, "MySQL", MIN_VERSION_MYSQL
)
@@ -551,7 +604,7 @@ def setup_connection_for_dialect(
version_string = result[0][0]
version = _extract_version_from_server_response(version_string)
if not version or version < MIN_VERSION_PGSQL:
- _fail_unsupported_version(
+ _raise_if_version_unsupported(
version or version_string, "PostgreSQL", MIN_VERSION_PGSQL
)
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index 2077b4a5e29ee5..a319024633cbae 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -186,7 +186,47 @@ async def async_remove_config_entry_device(
) -> bool:
"""Remove a device from a config entry."""
host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host
- (device_uid, ch) = get_device_uid_and_ch(device, host)
+ (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
+
+ if is_chime:
+ await host.api.get_state(cmd="GetDingDongList")
+ chime = host.api.chime(ch)
+ if (
+ chime is None
+ or chime.connect_state is None
+ or chime.connect_state < 0
+ or chime.channel not in host.api.channels
+ ):
+ _LOGGER.debug(
+ "Removing Reolink chime %s with id %s, "
+ "since it is not coupled to %s anymore",
+ device.name,
+ ch,
+ host.api.nvr_name,
+ )
+ return True
+
+ # remove the chime from the host
+ await chime.remove()
+ await host.api.get_state(cmd="GetDingDongList")
+ if chime.connect_state < 0:
+ _LOGGER.debug(
+ "Removed Reolink chime %s with id %s from %s",
+ device.name,
+ ch,
+ host.api.nvr_name,
+ )
+ return True
+
+ _LOGGER.warning(
+ "Cannot remove Reolink chime %s with id %s, because it is still connected "
+ "to %s, please first remove the chime "
+ "in the reolink app",
+ device.name,
+ ch,
+ host.api.nvr_name,
+ )
+ return False
if not host.api.is_nvr or ch is None:
_LOGGER.warning(
@@ -227,20 +267,24 @@ async def async_remove_config_entry_device(
def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost
-) -> tuple[list[str], int | None]:
+) -> tuple[list[str], int | None, bool]:
"""Get the channel and the split device_uid from a reolink DeviceEntry."""
device_uid = [
dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN
][0]
+ is_chime = False
if len(device_uid) < 2:
# NVR itself
ch = None
elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5:
ch = int(device_uid[1][2:])
+ elif device_uid[1].startswith("chime"):
+ ch = int(device_uid[1][5:])
+ is_chime = True
else:
ch = host.api.channel_for_uid(device_uid[1])
- return (device_uid, ch)
+ return (device_uid, ch, is_chime)
def migrate_entity_ids(
@@ -251,7 +295,7 @@ def migrate_entity_ids(
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
ch_device_ids = {}
for device in devices:
- (device_uid, ch) = get_device_uid_and_ch(device, host)
+ (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
if ch is None:
@@ -261,8 +305,8 @@ def migrate_entity_ids(
new_identifiers = {(DOMAIN, new_device_id)}
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
- if ch is None:
- continue # Do not consider the NVR itself
+ if ch is None or is_chime:
+ continue # Do not consider the NVR itself or chimes
ch_device_ids[device.id] = ch
if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch):
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index d19987c3bc6ddc..70c21849bc2ee6 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -117,18 +117,14 @@ async def async_setup_entry(
entities: list[ReolinkBinarySensorEntity] = []
for channel in reolink_data.host.api.channels:
entities.extend(
- [
- ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description)
- for entity_description in BINARY_PUSH_SENSORS
- if entity_description.supported(reolink_data.host.api, channel)
- ]
+ ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description)
+ for entity_description in BINARY_PUSH_SENSORS
+ if entity_description.supported(reolink_data.host.api, channel)
)
entities.extend(
- [
- ReolinkBinarySensorEntity(reolink_data, channel, entity_description)
- for entity_description in BINARY_SENSORS
- if entity_description.supported(reolink_data.host.api, channel)
- ]
+ ReolinkBinarySensorEntity(reolink_data, channel, entity_description)
+ for entity_description in BINARY_SENSORS
+ if entity_description.supported(reolink_data.host.api, channel)
)
async_add_entities(entities)
diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py
index 528807920d3aae..eba0570a3fb9e2 100644
--- a/homeassistant/components/reolink/button.py
+++ b/homeassistant/components/reolink/button.py
@@ -164,11 +164,9 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
- [
- ReolinkHostButtonEntity(reolink_data, entity_description)
- for entity_description in HOST_BUTTON_ENTITIES
- if entity_description.supported(reolink_data.host.api)
- ]
+ ReolinkHostButtonEntity(reolink_data, entity_description)
+ for entity_description in HOST_BUTTON_ENTITIES
+ if entity_description.supported(reolink_data.host.api)
)
async_add_entities(entities)
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index c07983175ae5e1..c47822e125c53b 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -5,7 +5,7 @@
from collections.abc import Callable
from dataclasses import dataclass
-from reolink_aio.api import DUAL_LENS_MODELS, Host
+from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -59,8 +59,9 @@ def __init__(
http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
+ self._dev_id = self._host.unique_id
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self._host.unique_id)},
+ identifiers={(DOMAIN, self._dev_id)},
connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)},
name=self._host.api.nvr_name,
model=self._host.api.model,
@@ -126,12 +127,14 @@ def __init__(
if self._host.api.is_nvr:
if self._host.api.supported(dev_ch, "UID"):
- dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}"
+ self._dev_id = (
+ f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}"
+ )
else:
- dev_id = f"{self._host.unique_id}_ch{dev_ch}"
+ self._dev_id = f"{self._host.unique_id}_ch{dev_ch}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, dev_id)},
+ identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, self._host.unique_id),
name=self._host.api.camera_name(dev_ch),
model=self._host.api.camera_model(dev_ch),
@@ -156,3 +159,39 @@ async def async_will_remove_from_hass(self) -> None:
self._host.async_unregister_update_cmd(cmd_key, self._channel)
await super().async_will_remove_from_hass()
+
+
+class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
+ """Parent class for Reolink chime entities connected."""
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ coordinator: DataUpdateCoordinator[None] | None = None,
+ ) -> None:
+ """Initialize ReolinkChimeCoordinatorEntity for a chime."""
+ super().__init__(reolink_data, chime.channel, coordinator)
+
+ self._chime = chime
+
+ self._attr_unique_id = (
+ f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
+ )
+ cam_dev_id = self._dev_id
+ self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._dev_id)},
+ via_device=(DOMAIN, cam_dev_id),
+ name=chime.name,
+ model="Reolink Chime",
+ manufacturer=self._host.api.manufacturer,
+ serial_number=str(chime.dev_id),
+ configuration_url=self._conf_url,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._chime.online and super().available
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index 539c2461204214..7ca4c2d7f2bcfa 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -206,6 +206,15 @@
},
"hdr": {
"default": "mdi:hdr"
+ },
+ "motion_tone": {
+ "default": "mdi:music-note"
+ },
+ "people_tone": {
+ "default": "mdi:music-note"
+ },
+ "visitor_tone": {
+ "default": "mdi:music-note"
}
},
"sensor": {
@@ -284,6 +293,9 @@
},
"pir_reduce_alarm": {
"default": "mdi:motion-sensor"
+ },
+ "led": {
+ "default": "mdi:lightning-bolt-circle"
}
}
},
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 7289dac682c3b3..9671a4b4fc14b8 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
- "requirements": ["reolink-aio==0.9.6"]
+ "requirements": ["reolink-aio==0.9.7"]
}
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index a4ea89c5b2682a..1dc99c886e1627 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import Any
-from reolink_aio.api import Host
+from reolink_aio.api import Chime, Host
from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.number import (
@@ -22,7 +22,11 @@
from . import ReolinkData
from .const import DOMAIN
-from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
+from .entity import (
+ ReolinkChannelCoordinatorEntity,
+ ReolinkChannelEntityDescription,
+ ReolinkChimeCoordinatorEntity,
+)
@dataclass(frozen=True, kw_only=True)
@@ -39,6 +43,18 @@ class ReolinkNumberEntityDescription(
value: Callable[[Host, int], float | None]
+@dataclass(frozen=True, kw_only=True)
+class ReolinkChimeNumberEntityDescription(
+ NumberEntityDescription,
+ ReolinkChannelEntityDescription,
+):
+ """A class that describes number entities for a chime."""
+
+ method: Callable[[Chime, float], Any]
+ mode: NumberMode = NumberMode.AUTO
+ value: Callable[[Chime], float | None]
+
+
NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="zoom",
@@ -459,6 +475,20 @@ class ReolinkNumberEntityDescription(
),
)
+CHIME_NUMBER_ENTITIES = (
+ ReolinkChimeNumberEntityDescription(
+ key="volume",
+ cmd_key="DingDongOpt",
+ translation_key="volume",
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=4,
+ value=lambda chime: chime.volume,
+ method=lambda chime, value: chime.set_option(volume=int(value)),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -468,12 +498,18 @@ async def async_setup_entry(
"""Set up a Reolink number entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
- async_add_entities(
+ entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [
ReolinkNumberEntity(reolink_data, channel, entity_description)
for entity_description in NUMBER_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
+ ]
+ entities.extend(
+ ReolinkChimeNumberEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_NUMBER_ENTITIES
+ for chime in reolink_data.host.api.chime_list
)
+ async_add_entities(entities)
class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
@@ -515,3 +551,36 @@ async def async_set_native_value(self, value: float) -> None:
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
+
+
+class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
+ """Base number entity class for Reolink IP cameras."""
+
+ entity_description: ReolinkChimeNumberEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeNumberEntityDescription,
+ ) -> None:
+ """Initialize Reolink chime number entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+
+ self._attr_mode = entity_description.mode
+
+ @property
+ def native_value(self) -> float | None:
+ """State of the number entity."""
+ return self.entity_description.value(self._chime)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Update the current value."""
+ try:
+ await self.entity_description.method(self._chime, value)
+ except InvalidParameterError as err:
+ raise ServiceValidationError(err) from err
+ except ReolinkError as err:
+ raise HomeAssistantError(err) from err
+ self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index cf32d7b45f9959..94cfdf6751bf75 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -8,6 +8,8 @@
from typing import Any
from reolink_aio.api import (
+ Chime,
+ ChimeToneEnum,
DayNightEnum,
HDREnum,
Host,
@@ -26,7 +28,11 @@
from . import ReolinkData
from .const import DOMAIN
-from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
+from .entity import (
+ ReolinkChannelCoordinatorEntity,
+ ReolinkChannelEntityDescription,
+ ReolinkChimeCoordinatorEntity,
+)
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +49,18 @@ class ReolinkSelectEntityDescription(
value: Callable[[Host, int], str] | None = None
+@dataclass(frozen=True, kw_only=True)
+class ReolinkChimeSelectEntityDescription(
+ SelectEntityDescription,
+ ReolinkChannelEntityDescription,
+):
+ """A class that describes select entities for a chime."""
+
+ get_options: list[str]
+ method: Callable[[Chime, str], Any]
+ value: Callable[[Chime], str]
+
+
def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
"""Get the quick reply file id from the message string."""
return [k for k, v in api.quick_reply_dict(ch).items() if v == mess][0]
@@ -132,6 +150,36 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
),
)
+CHIME_SELECT_ENTITIES = (
+ ReolinkChimeSelectEntityDescription(
+ key="motion_tone",
+ cmd_key="GetDingDongCfg",
+ translation_key="motion_tone",
+ entity_category=EntityCategory.CONFIG,
+ get_options=[method.name for method in ChimeToneEnum],
+ value=lambda chime: ChimeToneEnum(chime.tone("md")).name,
+ method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value),
+ ),
+ ReolinkChimeSelectEntityDescription(
+ key="people_tone",
+ cmd_key="GetDingDongCfg",
+ translation_key="people_tone",
+ entity_category=EntityCategory.CONFIG,
+ get_options=[method.name for method in ChimeToneEnum],
+ value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
+ method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
+ ),
+ ReolinkChimeSelectEntityDescription(
+ key="visitor_tone",
+ cmd_key="GetDingDongCfg",
+ translation_key="visitor_tone",
+ entity_category=EntityCategory.CONFIG,
+ get_options=[method.name for method in ChimeToneEnum],
+ value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name,
+ method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -141,12 +189,18 @@ async def async_setup_entry(
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
- async_add_entities(
+ entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
+ ]
+ entities.extend(
+ ReolinkChimeSelectEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_SELECT_ENTITIES
+ for chime in reolink_data.host.api.chime_list
)
+ async_add_entities(entities)
class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
@@ -196,3 +250,45 @@ async def async_select_option(self, option: str) -> None:
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
+
+
+class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
+ """Base select entity class for Reolink IP cameras."""
+
+ entity_description: ReolinkChimeSelectEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeSelectEntityDescription,
+ ) -> None:
+ """Initialize Reolink select entity for a chime."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+ self._log_error = True
+ self._attr_options = entity_description.get_options
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current option."""
+ try:
+ option = self.entity_description.value(self._chime)
+ except ValueError:
+ if self._log_error:
+ _LOGGER.exception("Reolink '%s' has an unknown value", self.name)
+ self._log_error = False
+ return None
+
+ self._log_error = True
+ return option
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ try:
+ await self.entity_description.method(self._chime, option)
+ except InvalidParameterError as err:
+ raise ServiceValidationError(err) from err
+ except ReolinkError as err:
+ raise HomeAssistantError(err) from err
+ self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py
index 419270a708290d..988b091735e64a 100644
--- a/homeassistant/components/reolink/sensor.py
+++ b/homeassistant/components/reolink/sensor.py
@@ -141,19 +141,15 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
- [
- ReolinkHostSensorEntity(reolink_data, entity_description)
- for entity_description in HOST_SENSORS
- if entity_description.supported(reolink_data.host.api)
- ]
+ ReolinkHostSensorEntity(reolink_data, entity_description)
+ for entity_description in HOST_SENSORS
+ if entity_description.supported(reolink_data.host.api)
)
entities.extend(
- [
- ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description)
- for entity_description in HDD_SENSORS
- for hdd_index in reolink_data.host.api.hdd_list
- if entity_description.supported(reolink_data.host.api, hdd_index)
- ]
+ ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description)
+ for entity_description in HDD_SENSORS
+ for hdd_index in reolink_data.host.api.hdd_list
+ if entity_description.supported(reolink_data.host.api, hdd_index)
)
async_add_entities(entities)
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index bcf1c71934d60c..cad09f71562039 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -491,6 +491,54 @@
"on": "[%key:common::state::on%]",
"auto": "Auto"
}
+ },
+ "motion_tone": {
+ "name": "Motion ringtone",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "citybird": "City bird",
+ "originaltune": "Original tune",
+ "pianokey": "Piano key",
+ "loop": "Loop",
+ "attraction": "Attraction",
+ "hophop": "Hop hop",
+ "goodday": "Good day",
+ "operetta": "Operetta",
+ "moonlight": "Moonlight",
+ "waybackhome": "Way back home"
+ }
+ },
+ "people_tone": {
+ "name": "Person ringtone",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]",
+ "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]",
+ "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]",
+ "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]",
+ "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]",
+ "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]",
+ "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]",
+ "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]",
+ "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
+ "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
+ }
+ },
+ "visitor_tone": {
+ "name": "Visitor ringtone",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]",
+ "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]",
+ "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]",
+ "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]",
+ "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]",
+ "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]",
+ "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]",
+ "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]",
+ "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
+ "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
+ }
}
},
"sensor": {
@@ -574,6 +622,9 @@
},
"pir_reduce_alarm": {
"name": "PIR reduce false alarm"
+ },
+ "led": {
+ "name": "LED"
}
}
}
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index cd74d774bb1db4..2bf7689b32f13b 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import Any
-from reolink_aio.api import Host
+from reolink_aio.api import Chime, Host
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@@ -22,6 +22,7 @@
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
+ ReolinkChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -49,6 +50,17 @@ class ReolinkNVRSwitchEntityDescription(
value: Callable[[Host], bool]
+@dataclass(frozen=True, kw_only=True)
+class ReolinkChimeSwitchEntityDescription(
+ SwitchEntityDescription,
+ ReolinkChannelEntityDescription,
+):
+ """A class that describes switch entities for a chime."""
+
+ method: Callable[[Chime, bool], Any]
+ value: Callable[[Chime], bool | None]
+
+
SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="ir_lights",
@@ -245,6 +257,17 @@ class ReolinkNVRSwitchEntityDescription(
),
)
+CHIME_SWITCH_ENTITIES = (
+ ReolinkChimeSwitchEntityDescription(
+ key="chime_led",
+ cmd_key="DingDongOpt",
+ translation_key="led",
+ entity_category=EntityCategory.CONFIG,
+ value=lambda chime: chime.led_state,
+ method=lambda chime, value: chime.set_option(led=value),
+ ),
+)
+
# Can be removed in HA 2025.2.0
DEPRECATED_HDR = ReolinkSwitchEntityDescription(
key="hdr",
@@ -266,18 +289,23 @@ async def async_setup_entry(
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
- entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [
+ entities: list[
+ ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity
+ ] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
- [
- ReolinkNVRSwitchEntity(reolink_data, entity_description)
- for entity_description in NVR_SWITCH_ENTITIES
- if entity_description.supported(reolink_data.host.api)
- ]
+ ReolinkNVRSwitchEntity(reolink_data, entity_description)
+ for entity_description in NVR_SWITCH_ENTITIES
+ if entity_description.supported(reolink_data.host.api)
+ )
+ entities.extend(
+ ReolinkChimeSwitchEntity(reolink_data, chime, entity_description)
+ for entity_description in CHIME_SWITCH_ENTITIES
+ for chime in reolink_data.host.api.chime_list
)
# Can be removed in HA 2025.2.0
@@ -378,3 +406,40 @@ async def async_turn_off(self, **kwargs: Any) -> None:
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
+
+
+class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
+ """Base switch entity class for a chime."""
+
+ entity_description: ReolinkChimeSwitchEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ chime: Chime,
+ entity_description: ReolinkChimeSwitchEntityDescription,
+ ) -> None:
+ """Initialize Reolink switch entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, chime)
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if switch is on."""
+ return self.entity_description.value(self._chime)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ try:
+ await self.entity_description.method(self._chime, True)
+ except ReolinkError as err:
+ raise HomeAssistantError(err) from err
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ try:
+ await self.entity_description.method(self._chime, False)
+ except ReolinkError as err:
+ raise HomeAssistantError(err) from err
+ self.async_write_ha_state()
diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py
index da3dafe0130bf9..9b710c6576d965 100644
--- a/homeassistant/components/reolink/update.py
+++ b/homeassistant/components/reolink/update.py
@@ -81,11 +81,9 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
- [
- ReolinkHostUpdateEntity(reolink_data, entity_description)
- for entity_description in HOST_UPDATE_ENTITIES
- if entity_description.supported(reolink_data.host.api)
- ]
+ ReolinkHostUpdateEntity(reolink_data, entity_description)
+ for entity_description in HOST_UPDATE_ENTITIES
+ if entity_description.supported(reolink_data.host.api)
)
async_add_entities(entities)
diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py
index b7cdee2e039711..59239ad6744835 100644
--- a/homeassistant/components/rest/__init__.py
+++ b/homeassistant/components/rest/__init__.py
@@ -202,19 +202,14 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res
timeout: int = config[CONF_TIMEOUT]
encoding: str = config[CONF_ENCODING]
if resource_template is not None:
- resource_template.hass = hass
resource = resource_template.async_render(parse_result=False)
if payload_template is not None:
- payload_template.hass = hass
payload = payload_template.async_render(parse_result=False)
if not resource:
raise HomeAssistantError("Resource not set for RestData")
- template.attach(hass, headers)
- template.attach(hass, params)
-
auth: httpx.DigestAuth | tuple[str, str] | None = None
if username and password:
if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py
index e8119a40f8c473..c976506d1badd0 100644
--- a/homeassistant/components/rest/binary_sensor.py
+++ b/homeassistant/components/rest/binary_sensor.py
@@ -133,8 +133,6 @@ def __init__(
)
self._previous_data = None
self._value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- if (value_template := self._value_template) is not None:
- value_template.hass = hass
@property
def available(self) -> bool:
diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py
index c8314d1870704a..1ca3c55e2b23f8 100644
--- a/homeassistant/components/rest/notify.py
+++ b/homeassistant/components/rest/notify.py
@@ -172,7 +172,6 @@ def _data_template_creator(value: Any) -> Any:
}
if not isinstance(value, Template):
return value
- value.hass = self._hass
return value.async_render(kwargs, parse_result=False)
if self._data:
diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py
index d7bb0ea33fb0ae..fc6ce8c674926c 100644
--- a/homeassistant/components/rest/sensor.py
+++ b/homeassistant/components/rest/sensor.py
@@ -139,8 +139,6 @@ def __init__(
config[CONF_FORCE_UPDATE],
)
self._value_template = config.get(CONF_VALUE_TEMPLATE)
- if (value_template := self._value_template) is not None:
- value_template.hass = hass
self._json_attrs = config.get(CONF_JSON_ATTRS)
self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH)
self._attr_extra_state_attributes = {}
diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py
index d01aab2cf9f4cd..e4bb1f797d94b6 100644
--- a/homeassistant/components/rest/switch.py
+++ b/homeassistant/components/rest/switch.py
@@ -151,14 +151,6 @@ def __init__(
self._timeout: int = config[CONF_TIMEOUT]
self._verify_ssl: bool = config[CONF_VERIFY_SSL]
- self._body_on.hass = hass
- self._body_off.hass = hass
- if (is_on_template := self._is_on_template) is not None:
- is_on_template.hass = hass
-
- template.attach(hass, self._headers)
- template.attach(hass, self._params)
-
async def async_added_to_hass(self) -> None:
"""Handle adding to Home Assistant."""
await super().async_added_to_hass()
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
index b6945c5ce98a50..ee93fde35fa13b 100644
--- a/homeassistant/components/rest_command/__init__.py
+++ b/homeassistant/components/rest_command/__init__.py
@@ -96,7 +96,6 @@ def async_register_rest_command(name: str, command_config: dict[str, Any]) -> No
method = command_config[CONF_METHOD]
template_url = command_config[CONF_URL]
- template_url.hass = hass
auth = None
if CONF_USERNAME in command_config:
@@ -107,11 +106,8 @@ def async_register_rest_command(name: str, command_config: dict[str, Any]) -> No
template_payload = None
if CONF_PAYLOAD in command_config:
template_payload = command_config[CONF_PAYLOAD]
- template_payload.hass = hass
template_headers = command_config.get(CONF_HEADERS, {})
- for template_header in template_headers.values():
- template_header.hass = hass
content_type = command_config.get(CONF_CONTENT_TYPE)
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index 3743faa32d89e1..d107a0bee8b433 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -174,7 +174,7 @@ async def setup_device_v1(
if networking is None:
# If the api does not return an error but does return None for
# get_networking - then we need to go through cache checking.
- raise RoborockException("Networking request returned None.")
+ raise RoborockException("Networking request returned None.") # noqa: TRY301
except RoborockException as err:
_LOGGER.warning(
"Not setting up %s because we could not get the network information of the device. "
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index f7fc58161a879b..81a10e26415f6f 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -69,7 +69,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
GET_MAPS_SERVICE_NAME,
- {},
+ None,
RoborockVacuum.get_maps.__name__,
supports_response=SupportsResponse.ONLY,
)
diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py
index 0620207a8ee818..7515f375054227 100644
--- a/homeassistant/components/roku/__init__.py
+++ b/homeassistant/components/roku/__init__.py
@@ -5,13 +5,10 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import RokuDataUpdateCoordinator
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.MEDIA_PLAYER,
diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py
index debff5a6e96de6..89624c922e628e 100644
--- a/homeassistant/components/rss_feed_template/__init__.py
+++ b/homeassistant/components/rss_feed_template/__init__.py
@@ -49,18 +49,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
requires_auth: bool = feedconfig["requires_api_password"]
- title: Template | None
- if (title := feedconfig.get("title")) is not None:
- title.hass = hass
-
items: list[dict[str, Template]] = feedconfig["items"]
- for item in items:
- if "title" in item:
- item["title"].hass = hass
- if "description" in item:
- item["description"].hass = hass
-
- rss_view = RssView(url, requires_auth, title, items)
+ rss_view = RssView(url, requires_auth, feedconfig.get("title"), items)
hass.http.register_view(rss_view)
return True
diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py
index 1560a4cd332025..8627c636ef2ee1 100644
--- a/homeassistant/components/russound_rio/__init__.py
+++ b/homeassistant/components/russound_rio/__init__.py
@@ -7,8 +7,8 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS
@@ -22,13 +22,28 @@
async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool:
"""Set up a config entry."""
- russ = Russound(hass.loop, entry.data[CONF_HOST], entry.data[CONF_PORT])
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+ russ = Russound(hass.loop, host, port)
+
+ @callback
+ def is_connected_updated(connected: bool) -> None:
+ if connected:
+ _LOGGER.warning("Reconnected to controller at %s:%s", host, port)
+ else:
+ _LOGGER.warning(
+ "Disconnected from controller at %s:%s",
+ host,
+ port,
+ )
+
+ russ.add_connection_callback(is_connected_updated)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
except RUSSOUND_RIO_EXCEPTIONS as err:
- raise ConfigEntryError(err) from err
+ raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err
entry.runtime_data = russ
diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py
new file mode 100644
index 00000000000000..0e4d5cf7dde7da
--- /dev/null
+++ b/homeassistant/components/russound_rio/entity.py
@@ -0,0 +1,86 @@
+"""Base entity for Russound RIO integration."""
+
+from collections.abc import Awaitable, Callable, Coroutine
+from functools import wraps
+from typing import Any, Concatenate
+
+from aiorussound import Controller
+
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS
+
+
+def command[_EntityT: RussoundBaseEntity, **_P](
+ func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
+) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
+ """Wrap async calls to raise on request error."""
+
+ @wraps(func)
+ async def decorator(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
+ """Wrap all command methods."""
+ try:
+ await func(self, *args, **kwargs)
+ except RUSSOUND_RIO_EXCEPTIONS as exc:
+ raise HomeAssistantError(
+ f"Error executing {func.__name__} on entity {self.entity_id},"
+ ) from exc
+
+ return decorator
+
+
+class RussoundBaseEntity(Entity):
+ """Russound Base Entity."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ controller: Controller,
+ ) -> None:
+ """Initialize the entity."""
+ self._instance = controller.instance
+ self._controller = controller
+ self._primary_mac_address = (
+ controller.mac_address or controller.parent_controller.mac_address
+ )
+ self._device_identifier = (
+ self._controller.mac_address
+ or f"{self._primary_mac_address}-{self._controller.controller_id}"
+ )
+ self._attr_device_info = DeviceInfo(
+ configuration_url=f"http://{self._instance.host}",
+ # Use MAC address of Russound device as identifier
+ identifiers={(DOMAIN, self._device_identifier)},
+ manufacturer="Russound",
+ name=controller.controller_type,
+ model=controller.controller_type,
+ sw_version=controller.firmware_version,
+ )
+ if controller.parent_controller:
+ self._attr_device_info["via_device"] = (
+ DOMAIN,
+ controller.parent_controller.mac_address,
+ )
+ else:
+ self._attr_device_info["connections"] = {
+ (CONNECTION_NETWORK_MAC, controller.mac_address)
+ }
+
+ @callback
+ def _is_connected_updated(self, connected: bool) -> None:
+ """Update the state when the device is ready to receive commands or is unavailable."""
+ self._attr_available = connected
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ self._instance.add_connection_callback(self._is_connected_updated)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Remove callbacks."""
+ self._instance.remove_connection_callback(self._is_connected_updated)
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index be5dd86793ffb6..6c473d948744d1 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"iot_class": "local_push",
"loggers": ["aiorussound"],
- "requirements": ["aiorussound==2.2.0"]
+ "quality_scale": "silver",
+ "requirements": ["aiorussound==2.3.2"]
}
diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py
index 1489f12e59c671..20aaf0f3c08a26 100644
--- a/homeassistant/components/russound_rio/media_player.py
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -17,13 +17,13 @@
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import RussoundConfigEntry
from .const import DOMAIN, MP_FEATURES_BY_FLAG
+from .entity import RussoundBaseEntity, command
_LOGGER = logging.getLogger(__name__)
@@ -107,13 +107,11 @@ def on_stop(event):
async_add_entities(entities)
-class RussoundZoneDevice(MediaPlayerEntity):
+class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
"""Representation of a Russound Zone."""
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_media_content_type = MediaType.MUSIC
- _attr_should_poll = False
- _attr_has_entity_name = True
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
@@ -124,25 +122,11 @@ class RussoundZoneDevice(MediaPlayerEntity):
def __init__(self, zone: Zone, sources: dict[int, Source]) -> None:
"""Initialize the zone device."""
- self._controller = zone.controller
+ super().__init__(zone.controller)
self._zone = zone
self._sources = sources
self._attr_name = zone.name
- self._attr_unique_id = f"{self._controller.mac_address}-{zone.device_str()}"
- self._attr_device_info = DeviceInfo(
- # Use MAC address of Russound device as identifier
- identifiers={(DOMAIN, self._controller.mac_address)},
- connections={(CONNECTION_NETWORK_MAC, self._controller.mac_address)},
- manufacturer="Russound",
- name=self._controller.controller_type,
- model=self._controller.controller_type,
- sw_version=self._controller.firmware_version,
- )
- if self._controller.parent_controller:
- self._attr_device_info["via_device"] = (
- DOMAIN,
- self._controller.parent_controller.mac_address,
- )
+ self._attr_unique_id = f"{self._primary_mac_address}-{zone.device_str()}"
for flag, feature in MP_FEATURES_BY_FLAG.items():
if flag in zone.instance.supported_features:
self._attr_supported_features |= feature
@@ -156,8 +140,14 @@ def _callback_handler(self, device_str, *args):
async def async_added_to_hass(self) -> None:
"""Register callback handlers."""
+ await super().async_added_to_hass()
self._zone.add_callback(self._callback_handler)
+ async def async_will_remove_from_hass(self) -> None:
+ """Remove callbacks."""
+ await super().async_will_remove_from_hass()
+ self._zone.remove_callback(self._callback_handler)
+
def _current_source(self) -> Source:
return self._zone.fetch_current_source()
@@ -210,19 +200,23 @@ def volume_level(self):
"""
return float(self._zone.volume or "0") / 50.0
+ @command
async def async_turn_off(self) -> None:
"""Turn off the zone."""
await self._zone.zone_off()
+ @command
async def async_turn_on(self) -> None:
"""Turn on the zone."""
await self._zone.zone_on()
+ @command
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level."""
rvol = int(volume * 50.0)
await self._zone.set_volume(rvol)
+ @command
async def async_select_source(self, source: str) -> None:
"""Select the source input for this zone."""
for source_id, src in self._sources.items():
@@ -231,10 +225,12 @@ async def async_select_source(self, source: str) -> None:
await self._zone.select_source(source_id)
break
+ @command
async def async_volume_up(self) -> None:
"""Step the volume up."""
await self._zone.volume_up()
+ @command
async def async_volume_down(self) -> None:
"""Step the volume down."""
await self._zone.volume_down()
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
index 992c86d5d7edf2..f3b967a485e8aa 100644
--- a/homeassistant/components/samsungtv/__init__.py
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -23,11 +23,7 @@
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import (
- config_validation as cv,
- device_registry as dr,
- entity_registry as er,
-)
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from .bridge import (
@@ -53,7 +49,6 @@
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator]
diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py
index 209b6c38cda13f..8ff549406350ee 100644
--- a/homeassistant/components/satel_integra/binary_sensor.py
+++ b/homeassistant/components/satel_integra/binary_sensor.py
@@ -109,10 +109,11 @@ def name(self):
return self._name
@property
- def icon(self):
+ def icon(self) -> str | None:
"""Icon for device by its type."""
if self._zone_type is BinarySensorDeviceClass.SMOKE:
return "mdi:fire"
+ return None
@property
def is_on(self):
diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json
index c6dfc443bb81b7..5619cf7b3126a1 100644
--- a/homeassistant/components/schlage/manifest.json
+++ b/homeassistant/components/schlage/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
- "requirements": ["pyschlage==2024.6.0"]
+ "requirements": ["pyschlage==2024.8.0"]
}
diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py
index ceaf1e63a9d518..dd84767ad4124c 100644
--- a/homeassistant/components/scrape/sensor.py
+++ b/homeassistant/components/scrape/sensor.py
@@ -67,10 +67,6 @@ async def async_setup_platform(
entities: list[ScrapeSensor] = []
for sensor_config in sensors_config:
- value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
-
trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
@@ -85,7 +81,7 @@ async def async_setup_platform(
sensor_config[CONF_SELECT],
sensor_config.get(CONF_ATTRIBUTE),
sensor_config[CONF_INDEX],
- value_template,
+ sensor_config.get(CONF_VALUE_TEMPLATE),
True,
)
)
diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py
index 27d41dafcd1db7..24f7d8bffeaf0f 100644
--- a/homeassistant/components/select/__init__.py
+++ b/homeassistant/components/select/__init__.py
@@ -66,13 +66,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_SELECT_FIRST,
- {},
+ None,
SelectEntity.async_first.__name__,
)
component.async_register_entity_service(
SERVICE_SELECT_LAST,
- {},
+ None,
SelectEntity.async_last.__name__,
)
diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py
index 28408c0cb7d2f0..58e993ad6e0c24 100644
--- a/homeassistant/components/sense/__init__.py
+++ b/homeassistant/components/sense/__init__.py
@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import timedelta
+from functools import partial
import logging
from typing import Any
@@ -80,8 +81,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo
client_session = async_get_clientsession(hass)
- gateway = ASyncSenseable(
- api_timeout=timeout, wss_timeout=timeout, client_session=client_session
+ # Creating the AsyncSenseable object loads
+ # ssl certificates which does blocking IO
+ gateway = await hass.async_add_executor_job(
+ partial(
+ ASyncSenseable,
+ api_timeout=timeout,
+ wss_timeout=timeout,
+ client_session=client_session,
+ )
)
gateway.rate_limit = ACTIVE_UPDATE_RATE
diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py
index 25c6898aec891d..dab80b99e1a5f7 100644
--- a/homeassistant/components/sense/config_flow.py
+++ b/homeassistant/components/sense/config_flow.py
@@ -1,6 +1,7 @@
"""Config flow for Sense integration."""
from collections.abc import Mapping
+from functools import partial
import logging
from typing import Any
@@ -48,8 +49,15 @@ async def validate_input(self, data):
timeout = self._auth_data[CONF_TIMEOUT]
client_session = async_get_clientsession(self.hass)
- self._gateway = ASyncSenseable(
- api_timeout=timeout, wss_timeout=timeout, client_session=client_session
+ # Creating the AsyncSenseable object loads
+ # ssl certificates which does blocking IO
+ self._gateway = await self.hass.async_add_executor_job(
+ partial(
+ ASyncSenseable,
+ api_timeout=timeout,
+ wss_timeout=timeout,
+ client_session=client_session,
+ )
)
self._gateway.rate_limit = ACTIVE_UPDATE_RATE
await self._gateway.authenticate(
diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py
index 8c042621db6462..904d493a863516 100644
--- a/homeassistant/components/sentry/__init__.py
+++ b/homeassistant/components/sentry/__init__.py
@@ -17,7 +17,7 @@
__version__ as current_version,
)
from homeassistant.core import HomeAssistant, get_release_channel
-from homeassistant.helpers import config_validation as cv, entity_platform, instance_id
+from homeassistant.helpers import entity_platform, instance_id
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.loader import Integration, async_get_custom_components
@@ -36,12 +36,9 @@
DEFAULT_LOGGING_EVENT_LEVEL,
DEFAULT_LOGGING_LEVEL,
DEFAULT_TRACING_SAMPLE_RATE,
- DOMAIN,
ENTITY_COMPONENTS,
)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$")
diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py
index e3fee36c09ee31..e7c39d97f6a26c 100644
--- a/homeassistant/components/serial/sensor.py
+++ b/homeassistant/components/serial/sensor.py
@@ -93,9 +93,7 @@ async def async_setup_platform(
xonxoff = config.get(CONF_XONXOFF)
rtscts = config.get(CONF_RTSCTS)
dsrdtr = config.get(CONF_DSRDTR)
-
- if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None:
- value_template.hass = hass
+ value_template = config.get(CONF_VALUE_TEMPLATE)
sensor = SerialSensor(
name,
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index ab1e58583d9587..b77f45afb3f647 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -54,7 +54,8 @@ async def async_setup_entry(
) -> None:
"""Set up climate device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
- return async_setup_rpc_entry(hass, config_entry, async_add_entities)
+ async_setup_rpc_entry(hass, config_entry, async_add_entities)
+ return
coordinator = config_entry.runtime_data.block
assert coordinator
diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py
index cb3bca6aa478e0..c80d1e84d6fec4 100644
--- a/homeassistant/components/shelly/config_flow.py
+++ b/homeassistant/components/shelly/config_flow.py
@@ -279,6 +279,8 @@ async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
+ if discovery_info.ip_address.version == 6:
+ return self.async_abort(reason="ipv6_not_supported")
host = discovery_info.host
# First try to get the mac address from the name
# so we can avoid making another connection to the
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 1e65a51733d17b..c742b45632ccf5 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
- "requirements": ["aioshelly==11.1.0"],
+ "requirements": ["aioshelly==11.2.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json
index 8ae4ff1f3e4fc9..f76319eb08c5be 100644
--- a/homeassistant/components/shelly/strings.json
+++ b/homeassistant/components/shelly/strings.json
@@ -52,7 +52,8 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used."
+ "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
+ "ipv6_not_supported": "IPv6 is not supported."
}
},
"device_automation": {
diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py
index 21d42f8912f02c..9321bc3232fcd0 100644
--- a/homeassistant/components/signal_messenger/notify.py
+++ b/homeassistant/components/signal_messenger/notify.py
@@ -166,7 +166,7 @@ def get_attachments_as_bytes(
and int(str(resp.headers.get("Content-Length")))
> attachment_size_limit
):
- raise ValueError(
+ raise ValueError( # noqa: TRY301
"Attachment too large (Content-Length reports {}). Max size: {}"
" bytes".format(
int(str(resp.headers.get("Content-Length"))),
@@ -179,7 +179,7 @@ def get_attachments_as_bytes(
for chunk in resp.iter_content(1024):
size += len(chunk)
if size > attachment_size_limit:
- raise ValueError(
+ raise ValueError( # noqa: TRY301
f"Attachment too large (Stream reports {size}). "
f"Max size: {CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES} bytes"
)
diff --git a/homeassistant/components/simplefin/manifest.json b/homeassistant/components/simplefin/manifest.json
index f3e312d9de5108..a790e64c57826e 100644
--- a/homeassistant/components/simplefin/manifest.json
+++ b/homeassistant/components/simplefin/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["simplefin"],
- "requirements": ["simplefin4py==0.0.16"]
+ "requirements": ["simplefin4py==0.0.18"]
}
diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py
index 2fac42cbac5327..b2167a2c014cc0 100644
--- a/homeassistant/components/simplefin/sensor.py
+++ b/homeassistant/components/simplefin/sensor.py
@@ -4,6 +4,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
from simplefin4py import Account
@@ -13,6 +14,7 @@
SensorEntityDescription,
SensorStateClass,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -25,7 +27,7 @@
class SimpleFinSensorEntityDescription(SensorEntityDescription):
"""Describes a sensor entity."""
- value_fn: Callable[[Account], StateType]
+ value_fn: Callable[[Account], StateType | datetime]
icon_fn: Callable[[Account], str] | None = None
unit_fn: Callable[[Account], str] | None = None
@@ -40,6 +42,13 @@ class SimpleFinSensorEntityDescription(SensorEntityDescription):
unit_fn=lambda account: account.currency,
icon_fn=lambda account: account.inferred_account_type,
),
+ SimpleFinSensorEntityDescription(
+ key="age",
+ translation_key="age",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda account: account.balance_date,
+ ),
)
@@ -70,7 +79,7 @@ class SimpleFinSensor(SimpleFinEntity, SensorEntity):
entity_description: SimpleFinSensorEntityDescription
@property
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | datetime | None:
"""Return the state."""
return self.entity_description.value_fn(self.account_data)
diff --git a/homeassistant/components/simplefin/strings.json b/homeassistant/components/simplefin/strings.json
index c54520a0451b4e..d6690e604c5602 100644
--- a/homeassistant/components/simplefin/strings.json
+++ b/homeassistant/components/simplefin/strings.json
@@ -24,6 +24,9 @@
"sensor": {
"balance": {
"name": "Balance"
+ },
+ "age": {
+ "name": "Data age"
}
}
}
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 29f53eafffb220..b23358c985fff8 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -212,8 +212,6 @@
EVENT_USER_INITIATED_TEST,
]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
@callback
def _async_get_system_for_service_call(
diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py
index 216e111b7dbaa0..801ca4f2bee855 100644
--- a/homeassistant/components/siren/__init__.py
+++ b/homeassistant/components/siren/__init__.py
@@ -129,11 +129,11 @@ async def async_handle_turn_on_service(
[SirenEntityFeature.TURN_ON],
)
component.async_register_entity_service(
- SERVICE_TURN_OFF, {}, "async_turn_off", [SirenEntityFeature.TURN_OFF]
+ SERVICE_TURN_OFF, None, "async_turn_off", [SirenEntityFeature.TURN_OFF]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF],
)
diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py
index a18b211962ab88..28f9dd203ff993 100644
--- a/homeassistant/components/slack/notify.py
+++ b/homeassistant/components/slack/notify.py
@@ -291,7 +291,6 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None:
if ATTR_FILE not in data:
if ATTR_BLOCKS_TEMPLATE in data:
value = cv.template_complex(data[ATTR_BLOCKS_TEMPLATE])
- template.attach(self._hass, value)
blocks = template.render_complex(value)
elif ATTR_BLOCKS in data:
blocks = data[ATTR_BLOCKS]
diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py
index e2593dd7b10bb2..6b0da00b132d85 100644
--- a/homeassistant/components/smartthings/smartapp.py
+++ b/homeassistant/components/smartthings/smartapp.py
@@ -16,6 +16,7 @@
CAPABILITIES,
CLASSIFICATION_AUTOMATION,
App,
+ AppEntity,
AppOAuth,
AppSettings,
InstalledAppStatus,
@@ -63,7 +64,7 @@ def format_unique_id(app_id: str, location_id: str) -> str:
return f"{app_id}_{location_id}"
-async def find_app(hass: HomeAssistant, api):
+async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None:
"""Find an existing SmartApp for this installation of hass."""
apps = await api.apps()
for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]:
@@ -74,6 +75,7 @@ async def find_app(hass: HomeAssistant, api):
== hass.data[DOMAIN][CONF_INSTANCE_ID]
):
return app
+ return None
async def validate_installed_app(api, installed_app_id: str):
diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py
index 0918d6465ad8fb..bda411acde3b16 100644
--- a/homeassistant/components/snapcast/media_player.py
+++ b/homeassistant/components/snapcast/media_player.py
@@ -42,12 +42,12 @@ def register_services():
"""Register snapcast services."""
platform = entity_platform.async_get_current_platform()
- platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot")
- platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore")
+ platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot")
+ platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, handle_async_join
)
- platform.async_register_entity_service(SERVICE_UNJOIN, {}, handle_async_unjoin)
+ platform.async_register_entity_service(SERVICE_UNJOIN, None, handle_async_unjoin)
platform.async_register_entity_service(
SERVICE_SET_LATENCY,
{vol.Required(ATTR_LATENCY): cv.positive_int},
diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py
index fb7b87403cba23..4586d0600e9987 100644
--- a/homeassistant/components/snmp/sensor.py
+++ b/homeassistant/components/snmp/sensor.py
@@ -174,8 +174,6 @@ async def async_setup_platform(
trigger_entity_config[key] = config[key]
value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
data = SnmpData(request_args, baseoid, accept_errors, default_value)
async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)])
diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py
index e3ce09cbf48b21..92e27daed6c3fb 100644
--- a/homeassistant/components/snmp/switch.py
+++ b/homeassistant/components/snmp/switch.py
@@ -277,6 +277,11 @@ async def async_update(self) -> None:
):
self._state = False
else:
+ _LOGGER.warning(
+ "Invalid payload '%s' received for entity %s, state is unknown",
+ resrow[-1],
+ self.entity_id,
+ )
self._state = None
@property
diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py
index 41448bae98db32..206a2499494325 100644
--- a/homeassistant/components/solaredge/__init__.py
+++ b/homeassistant/components/solaredge/__init__.py
@@ -11,13 +11,10 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
-from .const import CONF_SITE_ID, DOMAIN, LOGGER
+from .const import CONF_SITE_ID, LOGGER
from .types import SolarEdgeConfigEntry, SolarEdgeData
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
PLATFORMS = [Platform.SENSOR]
diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json
index 0878d652f43abe..0c097b7146d4aa 100644
--- a/homeassistant/components/solarlog/manifest.json
+++ b/homeassistant/components/solarlog/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/solarlog",
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
- "requirements": ["solarlog_cli==0.1.5"]
+ "requirements": ["solarlog_cli==0.1.6"]
}
diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py
index ed9652de55aaa2..89796f5ce4617b 100644
--- a/homeassistant/components/somfy_mylink/__init__.py
+++ b/homeassistant/components/somfy_mylink/__init__.py
@@ -8,7 +8,6 @@
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv
from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS
@@ -16,8 +15,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Somfy MyLink from a config entry."""
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 4125466bd99559..590761752c5e5f 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -162,7 +162,9 @@ async def async_service_handle(service_call: ServiceCall) -> None:
"set_sleep_timer",
)
- platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, "clear_sleep_timer")
+ platform.async_register_entity_service(
+ SERVICE_CLEAR_TIMER, None, "clear_sleep_timer"
+ )
platform.async_register_entity_service(
SERVICE_UPDATE_ALARM,
diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py
index d77100a2236947..d339e861a131ab 100644
--- a/homeassistant/components/sonos/speaker.py
+++ b/homeassistant/components/sonos/speaker.py
@@ -826,9 +826,6 @@ def async_update_groups(self, event: SonosEvent) -> None:
f"{SONOS_VANISHED}-{uid}",
reason,
)
-
- if "zone_player_uui_ds_in_group" not in event.variables:
- return
self.event_stats.process(event)
self.hass.async_create_background_task(
self.create_update_groups_coro(event),
@@ -857,8 +854,7 @@ def _get_soco_group() -> list[str]:
async def _async_extract_group(event: SonosEvent | None) -> list[str]:
"""Extract group layout from a topology event."""
- group = event and event.zone_player_uui_ds_in_group
- if group:
+ if group := (event and getattr(event, "zone_player_uui_ds_in_group", None)):
assert isinstance(group, str)
return group.split(",")
@@ -867,11 +863,21 @@ async def _async_extract_group(event: SonosEvent | None) -> list[str]:
@callback
def _async_regroup(group: list[str]) -> None:
"""Rebuild internal group layout."""
+ _LOGGER.debug("async_regroup %s %s", self.zone_name, group)
if (
group == [self.soco.uid]
and self.sonos_group == [self]
and self.sonos_group_entities
):
+ # Single speakers do not have a coodinator, check and clear
+ if self.coordinator is not None:
+ _LOGGER.debug(
+ "Zone %s Cleared coordinator [%s]",
+ self.zone_name,
+ self.coordinator.zone_name,
+ )
+ self.coordinator = None
+ self.async_write_entity_states()
# Skip updating existing single speakers in polling mode
return
@@ -912,6 +918,11 @@ def _async_regroup(group: list[str]) -> None:
joined_speaker.coordinator = self
joined_speaker.sonos_group = sonos_group
joined_speaker.sonos_group_entities = sonos_group_entities
+ _LOGGER.debug(
+ "Zone %s Set coordinator [%s]",
+ joined_speaker.zone_name,
+ self.zone_name,
+ )
joined_speaker.async_write_entity_states()
_LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities)
diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py
index bb025d699fc7bc..3d9467f204144d 100644
--- a/homeassistant/components/spc/__init__.py
+++ b/homeassistant/components/spc/__init__.py
@@ -41,7 +41,7 @@
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SPC component."""
- async def async_upate_callback(spc_object):
+ async def async_update_callback(spc_object):
if isinstance(spc_object, Area):
async_dispatcher_send(hass, SIGNAL_UPDATE_ALARM.format(spc_object.id))
elif isinstance(spc_object, Zone):
@@ -54,7 +54,7 @@ async def async_upate_callback(spc_object):
session=session,
api_url=config[DOMAIN].get(CONF_API_URL),
ws_url=config[DOMAIN].get(CONF_WS_URL),
- async_callback=async_upate_callback,
+ async_callback=async_update_callback,
)
hass.data[DATA_API] = spc
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index bd1bcdfd43ed82..3653bdb149a862 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -31,7 +31,7 @@
from . import SpotifyConfigEntry
from .browse_media import async_browse_media_internal
-from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES
+from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES
from .models import HomeAssistantSpotifyData
from .util import fetch_image_url
@@ -138,10 +138,6 @@ def __init__(
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://open.spotify.com",
)
-
- self._scope_ok = set(data.session.token["scope"].split(" ")).issuperset(
- SPOTIFY_SCOPES
- )
self._currently_playing: dict | None = {}
self._playlist: dict | None = None
self._restricted_device: bool = False
@@ -459,13 +455,6 @@ async def async_browse_media(
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
- if not self._scope_ok:
- _LOGGER.debug(
- "Spotify scopes are not set correctly, this can impact features such as"
- " media browsing"
- )
- raise NotImplementedError
-
return await async_browse_media_internal(
self.hass,
self.data.client,
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
index f09f7ae95cf539..1d033728c0d0de 100644
--- a/homeassistant/components/sql/sensor.py
+++ b/homeassistant/components/sql/sensor.py
@@ -81,9 +81,6 @@ async def async_setup_platform(
unique_id: str | None = conf.get(CONF_UNIQUE_ID)
db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL))
- if value_template is not None:
- value_template.hass = hass
-
trigger_entity_config = {CONF_NAME: name}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in conf:
@@ -117,12 +114,10 @@ async def async_setup_entry(
value_template: Template | None = None
if template is not None:
try:
- value_template = Template(template)
+ value_template = Template(template, hass)
value_template.ensure_valid()
except TemplateError:
value_template = None
- if value_template is not None:
- value_template.hass = hass
name_template = Template(name, hass)
trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id}
diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py
index 9ccac13223bc80..95af3e8032ab96 100644
--- a/homeassistant/components/squeezebox/config_flow.py
+++ b/homeassistant/components/squeezebox/config_flow.py
@@ -3,7 +3,7 @@
import asyncio
from http import HTTPStatus
import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from pysqueezebox import Server, async_discover
import voluptuous as vol
@@ -102,7 +102,7 @@ def _discovery_callback(server):
# update with suggested values from discovery
self.data_schema = _base_schema(self.discovery_info)
- async def _validate_input(self, data):
+ async def _validate_input(self, data: dict[str, Any]) -> str | None:
"""Validate the user input allows us to connect.
Retrieve unique id and abort if already configured.
@@ -129,6 +129,8 @@ async def _validate_input(self, data):
await self.async_set_unique_id(status["uuid"])
self._abort_if_unique_id_configured()
+ return None
+
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index aaf64c34ddf293..552b8ed800c49d 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -281,10 +281,11 @@ async def async_will_remove_from_hass(self) -> None:
self.hass.data[DOMAIN][KNOWN_PLAYERS].remove(self)
@property
- def volume_level(self):
+ def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
if self._player.volume:
return int(float(self._player.volume)) / 100.0
+ return None
@property
def is_volume_muted(self):
diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py
index c13586d0bc37ff..fbb7fa9acdcec8 100644
--- a/homeassistant/components/starline/config_flow.py
+++ b/homeassistant/components/starline/config_flow.py
@@ -214,8 +214,7 @@ async def _async_authenticate_user(self, error=None):
self._captcha_image = data["captchaImg"]
return self._async_form_auth_captcha(error)
- # pylint: disable=broad-exception-raised
- raise Exception(data)
+ raise Exception(data) # noqa: TRY002, TRY301
except Exception as err: # noqa: BLE001
_LOGGER.error("Error auth user: %s", err)
return self._async_form_auth_user(ERROR_AUTH_USER)
diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py
index a53751a3b23148..f9bd304c1e1d17 100644
--- a/homeassistant/components/starline/sensor.py
+++ b/homeassistant/components/starline/sensor.py
@@ -6,6 +6,7 @@
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
+ SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -30,47 +31,57 @@
translation_key="battery",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="balance",
translation_key="balance",
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="ctemp",
translation_key="interior_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="etemp",
translation_key="engine_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="gsm_lvl",
translation_key="gsm_signal",
native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="fuel",
translation_key="fuel",
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="errors",
translation_key="errors",
+ native_unit_of_measurement="errors",
entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="mileage",
translation_key="mileage",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="gps_count",
translation_key="gps_count",
native_unit_of_measurement="satellites",
+ state_class=SensorStateClass.MEASUREMENT,
),
)
diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json
index 6f0c42f0882e46..14a8ed5a035454 100644
--- a/homeassistant/components/starline/strings.json
+++ b/homeassistant/components/starline/strings.json
@@ -114,9 +114,6 @@
"additional_channel": {
"name": "Additional channel"
},
- "horn": {
- "name": "Horn"
- },
"service_mode": {
"name": "Service mode"
}
@@ -127,12 +124,6 @@
}
}
},
- "issues": {
- "deprecated_horn_switch": {
- "title": "The Starline Horn switch entity is being removed",
- "description": "Using the Horn switch is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use Horn switch entity to instead use the Horn button entity."
- }
- },
"services": {
"update_state": {
"name": "Update state",
diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py
index 8ca736d2ac5204..1b48a72c7325b8 100644
--- a/homeassistant/components/starline/switch.py
+++ b/homeassistant/components/starline/switch.py
@@ -8,7 +8,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from .account import StarlineAccount, StarlineDevice
from .const import DOMAIN
@@ -27,11 +26,6 @@
key="out",
translation_key="additional_channel",
),
- # Deprecated and should be removed in 2024.8
- SwitchEntityDescription(
- key="poke",
- translation_key="horn",
- ),
SwitchEntityDescription(
key="valet",
translation_key="service_mode",
@@ -90,16 +84,6 @@ def is_on(self):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- if self._key == "poke":
- create_issue(
- self.hass,
- DOMAIN,
- "deprecated_horn_switch",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_horn_switch",
- )
self._account.api.set_car_state(self._device.device_id, self._key, True)
def turn_off(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py
index 255d75e3b79ea8..5080678e3ca94e 100644
--- a/homeassistant/components/stream/fmp4utils.py
+++ b/homeassistant/components/stream/fmp4utils.py
@@ -149,7 +149,8 @@ def get_codec_string(mp4_bytes: bytes) -> str:
def find_moov(mp4_io: BufferedIOBase) -> int:
"""Find location of moov atom in a BufferedIOBase mp4."""
index = 0
- while 1:
+ # Ruff doesn't understand this loop - the exception is always raised at the end
+ while 1: # noqa: RET503
mp4_io.seek(index)
box_header = mp4_io.read(8)
if len(box_header) != 8 or box_header[0:4] == b"\x00\x00\x00\x00":
diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py
index 1242c95269e199..dc1d0eb236c7d1 100644
--- a/homeassistant/components/swiss_public_transport/__init__.py
+++ b/homeassistant/components/swiss_public_transport/__init__.py
@@ -11,18 +11,32 @@
from homeassistant import config_entries, core
from homeassistant.const import Platform
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import ConfigType
from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, PLACEHOLDERS
from .coordinator import SwissPublicTransportDataUpdateCoordinator
from .helper import unique_id_from_config
+from .services import setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: core.HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Swiss public transport component."""
+ setup_services(hass)
+ return True
+
async def async_setup_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
@@ -44,7 +58,7 @@ async def async_setup_entry(
translation_key="request_timeout",
translation_placeholders={
"config_title": entry.title,
- "error": e,
+ "error": str(e),
},
) from e
except OpendataTransportError as e:
@@ -54,7 +68,7 @@ async def async_setup_entry(
translation_placeholders={
**PLACEHOLDERS,
"config_title": entry.title,
- "error": e,
+ "error": str(e),
},
) from e
diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py
index 32b6427ced57c4..c02f36f2f25131 100644
--- a/homeassistant/components/swiss_public_transport/const.py
+++ b/homeassistant/components/swiss_public_transport/const.py
@@ -9,12 +9,19 @@
CONF_VIA: Final = "via"
DEFAULT_NAME = "Next Destination"
+DEFAULT_UPDATE_TIME = 90
MAX_VIA = 5
-SENSOR_CONNECTIONS_COUNT = 3
+CONNECTIONS_COUNT = 3
+CONNECTIONS_MAX = 15
PLACEHOLDERS = {
"stationboard_url": "http://transport.opendata.ch/examples/stationboard.html",
"opendata_url": "http://transport.opendata.ch",
}
+
+ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id"
+ATTR_LIMIT: Final = "limit"
+
+SERVICE_FETCH_CONNECTIONS = "fetch_connections"
diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py
index ae7e1b2366ddcc..114215520acd3b 100644
--- a/homeassistant/components/swiss_public_transport/coordinator.py
+++ b/homeassistant/components/swiss_public_transport/coordinator.py
@@ -7,14 +7,17 @@
from typing import TypedDict
from opendata_transport import OpendataTransport
-from opendata_transport.exceptions import OpendataTransportError
+from opendata_transport.exceptions import (
+ OpendataTransportConnectionError,
+ OpendataTransportError,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
-from .const import DOMAIN, SENSOR_CONNECTIONS_COUNT
+from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -54,7 +57,7 @@ def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None:
hass,
_LOGGER,
name=DOMAIN,
- update_interval=timedelta(seconds=90),
+ update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME),
)
self._opendata = opendata
@@ -74,14 +77,21 @@ def nth_departure_time(self, i: int) -> datetime | None:
return None
async def _async_update_data(self) -> list[DataConnection]:
+ return await self.fetch_connections(limit=CONNECTIONS_COUNT)
+
+ async def fetch_connections(self, limit: int) -> list[DataConnection]:
+ """Fetch connections using the opendata api."""
+ self._opendata.limit = limit
try:
await self._opendata.async_get_data()
+ except OpendataTransportConnectionError as e:
+ _LOGGER.warning("Connection to transport.opendata.ch cannot be established")
+ raise UpdateFailed from e
except OpendataTransportError as e:
_LOGGER.warning(
"Unable to connect and retrieve data from transport.opendata.ch"
)
raise UpdateFailed from e
-
connections = self._opendata.connections
return [
DataConnection(
@@ -95,6 +105,6 @@ async def _async_update_data(self) -> list[DataConnection]:
remaining_time=str(self.remaining_time(connections[i]["departure"])),
delay=connections[i]["delay"],
)
- for i in range(SENSOR_CONNECTIONS_COUNT)
+ for i in range(limit)
if len(connections) > i and connections[i] is not None
]
diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json
index 10573b8f5c3369..7c2e543683476a 100644
--- a/homeassistant/components/swiss_public_transport/icons.json
+++ b/homeassistant/components/swiss_public_transport/icons.json
@@ -23,5 +23,8 @@
"default": "mdi:clock-plus"
}
}
+ },
+ "services": {
+ "fetch_connections": "mdi:bus-clock"
}
}
diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py
index 88a6dbecae47f0..c186b963705075 100644
--- a/homeassistant/components/swiss_public_transport/sensor.py
+++ b/homeassistant/components/swiss_public_transport/sensor.py
@@ -20,7 +20,7 @@
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN, SENSOR_CONNECTIONS_COUNT
+from .const import CONNECTIONS_COUNT, DOMAIN
from .coordinator import DataConnection, SwissPublicTransportDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ class SwissPublicTransportSensorEntityDescription(SensorEntityDescription):
value_fn=lambda data_connection: data_connection["departure"],
index=i,
)
- for i in range(SENSOR_CONNECTIONS_COUNT)
+ for i in range(CONNECTIONS_COUNT)
],
SwissPublicTransportSensorEntityDescription(
key="duration",
diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py
new file mode 100644
index 00000000000000..e8b7c6bd45888e
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/services.py
@@ -0,0 +1,89 @@
+"""Define services for the Swiss public transport integration."""
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+)
+from homeassistant.helpers.update_coordinator import UpdateFailed
+
+from .const import (
+ ATTR_CONFIG_ENTRY_ID,
+ ATTR_LIMIT,
+ CONNECTIONS_COUNT,
+ CONNECTIONS_MAX,
+ DOMAIN,
+ SERVICE_FETCH_CONNECTIONS,
+)
+
+SERVICE_FETCH_CONNECTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY_ID): str,
+ vol.Optional(ATTR_LIMIT, default=CONNECTIONS_COUNT): NumberSelector(
+ NumberSelectorConfig(
+ min=1, max=CONNECTIONS_MAX, mode=NumberSelectorMode.BOX
+ )
+ ),
+ }
+)
+
+
+def async_get_entry(
+ hass: HomeAssistant, config_entry_id: str
+) -> config_entries.ConfigEntry:
+ """Get the Swiss public transport config entry."""
+ if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_found",
+ translation_placeholders={"target": config_entry_id},
+ )
+ if entry.state is not ConfigEntryState.LOADED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="not_loaded",
+ translation_placeholders={"target": entry.title},
+ )
+ return entry
+
+
+def setup_services(hass: HomeAssistant) -> None:
+ """Set up the services for the Swiss public transport integration."""
+
+ async def async_fetch_connections(
+ call: ServiceCall,
+ ) -> ServiceResponse:
+ """Fetch a set of connections."""
+ config_entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
+ limit = call.data.get(ATTR_LIMIT) or CONNECTIONS_COUNT
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ try:
+ connections = await coordinator.fetch_connections(limit=int(limit))
+ except UpdateFailed as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={
+ "error": str(e),
+ },
+ ) from e
+ return {"connections": connections}
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_FETCH_CONNECTIONS,
+ async_fetch_connections,
+ schema=SERVICE_FETCH_CONNECTIONS_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/swiss_public_transport/services.yaml b/homeassistant/components/swiss_public_transport/services.yaml
new file mode 100644
index 00000000000000..d88dad2ca1f382
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/services.yaml
@@ -0,0 +1,14 @@
+fetch_connections:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: swiss_public_transport
+ limit:
+ example: 3
+ selector:
+ number:
+ min: 1
+ max: 15
+ step: 1
diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json
index 4f4bc0522fc183..29e73978538db1 100644
--- a/homeassistant/components/swiss_public_transport/strings.json
+++ b/homeassistant/components/swiss_public_transport/strings.json
@@ -49,12 +49,37 @@
}
}
},
+ "services": {
+ "fetch_connections": {
+ "name": "Fetch Connections",
+ "description": "Fetch a list of connections from the swiss public transport.",
+ "fields": {
+ "config_entry_id": {
+ "name": "Instance",
+ "description": "Swiss public transport instance to fetch connections for."
+ },
+ "limit": {
+ "name": "Limit",
+ "description": "Number of connections to fetch from [1-15]"
+ }
+ }
+ }
+ },
"exceptions": {
"invalid_data": {
"message": "Setup failed for entry {config_title} with invalid data, check at the [stationboard]({stationboard_url}) if your station names are valid.\n{error}"
},
"request_timeout": {
"message": "Timeout while connecting for entry {config_title}.\n{error}"
+ },
+ "cannot_connect": {
+ "message": "Cannot connect to server.\n{error}"
+ },
+ "not_loaded": {
+ "message": "{target} is not loaded."
+ },
+ "config_entry_not_found": {
+ "message": "Swiss public transport integration instance \"{target}\" not found."
}
}
}
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index 55e0a7a767e0c9..43971741e51744 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -79,9 +79,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
await component.async_setup(config)
- component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off")
- component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on")
- component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle")
+ component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off")
+ component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on")
+ component.async_register_entity_service(SERVICE_TOGGLE, None, "async_toggle")
return True
diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py
index b770c48c11c285..2e559ba9f3ba31 100644
--- a/homeassistant/components/switcher_kis/button.py
+++ b/homeassistant/components/switcher_kis/button.py
@@ -137,6 +137,7 @@ async def async_press(self) -> None:
try:
async with SwitcherType2Api(
+ self.coordinator.data.device_type,
self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py
index e6267e15305d0e..511630251f27bc 100644
--- a/homeassistant/components/switcher_kis/climate.py
+++ b/homeassistant/components/switcher_kis/climate.py
@@ -169,6 +169,7 @@ async def _async_control_breeze_device(self, **kwargs: Any) -> None:
try:
async with SwitcherType2Api(
+ self.coordinator.data.device_type,
self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py
index 258af3e1d5efe3..19c40d05e633d4 100644
--- a/homeassistant/components/switcher_kis/cover.py
+++ b/homeassistant/components/switcher_kis/cover.py
@@ -29,7 +29,7 @@
_LOGGER = logging.getLogger(__name__)
API_SET_POSITON = "set_position"
-API_STOP = "stop"
+API_STOP = "stop_shutter"
async def async_setup_entry(
@@ -98,6 +98,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None:
try:
async with SwitcherType2Api(
+ self.coordinator.data.device_type,
self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index 52b218fce9cc31..75ace60e942f01 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -7,6 +7,6 @@
"iot_class": "local_push",
"loggers": ["aioswitcher"],
"quality_scale": "platinum",
- "requirements": ["aioswitcher==3.4.3"],
+ "requirements": ["aioswitcher==4.0.2"],
"single_config_entry": true
}
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index aac5da10ae145e..c667a6dd47367b 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -117,6 +117,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None:
try:
async with SwitcherType1Api(
+ self.coordinator.data.device_type,
self.coordinator.data.ip_address,
self.coordinator.data.device_id,
self.coordinator.data.device_key,
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index d42dacca638781..3619619782e04c 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -13,7 +13,7 @@
from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
@@ -33,9 +33,6 @@
from .models import SynologyDSMData
from .service import async_setup_services
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
-
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json
index b1133fd61ad611..5d42188357b023 100644
--- a/homeassistant/components/synology_dsm/manifest.json
+++ b/homeassistant/components/synology_dsm/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
- "requirements": ["py-synologydsm-api==2.4.4"],
+ "requirements": ["py-synologydsm-api==2.5.2"],
"ssdp": [
{
"manufacturer": "Synology",
diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py
index ace5733c2222be..d35b262809c4dd 100644
--- a/homeassistant/components/synology_dsm/media_source.py
+++ b/homeassistant/components/synology_dsm/media_source.py
@@ -46,18 +46,24 @@ def __init__(self, identifier: str) -> None:
self.cache_key = None
self.file_name = None
self.is_shared = False
+ self.passphrase = ""
- if parts:
- self.unique_id = parts[0]
- if len(parts) > 1:
- self.album_id = parts[1]
- if len(parts) > 2:
- self.cache_key = parts[2]
- if len(parts) > 3:
- self.file_name = parts[3]
- if self.file_name.endswith(SHARED_SUFFIX):
- self.is_shared = True
- self.file_name = self.file_name.removesuffix(SHARED_SUFFIX)
+ self.unique_id = parts[0]
+
+ if len(parts) > 1:
+ album_parts = parts[1].split("_")
+ self.album_id = album_parts[0]
+ if len(album_parts) > 1:
+ self.passphrase = parts[1].replace(f"{self.album_id}_", "")
+
+ if len(parts) > 2:
+ self.cache_key = parts[2]
+
+ if len(parts) > 3:
+ self.file_name = parts[3]
+ if self.file_name.endswith(SHARED_SUFFIX):
+ self.is_shared = True
+ self.file_name = self.file_name.removesuffix(SHARED_SUFFIX)
class SynologyPhotosMediaSource(MediaSource):
@@ -135,7 +141,7 @@ async def _async_build_diskstations(
ret.extend(
BrowseMediaSource(
domain=DOMAIN,
- identifier=f"{item.identifier}/{album.album_id}",
+ identifier=f"{item.identifier}/{album.album_id}_{album.passphrase}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=album.name,
@@ -149,7 +155,7 @@ async def _async_build_diskstations(
# Request items of album
# Get Items
- album = SynoPhotosAlbum(int(identifier.album_id), "", 0)
+ album = SynoPhotosAlbum(int(identifier.album_id), "", 0, identifier.passphrase)
try:
album_items = await diskstation.api.photos.get_items_from_album(
album, 0, 1000
@@ -170,7 +176,12 @@ async def _async_build_diskstations(
ret.append(
BrowseMediaSource(
domain=DOMAIN,
- identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}{suffix}",
+ identifier=(
+ f"{identifier.unique_id}/"
+ f"{identifier.album_id}_{identifier.passphrase}/"
+ f"{album_item.thumbnail_cache_key}/"
+ f"{album_item.file_name}{suffix}"
+ ),
media_class=MediaClass.IMAGE,
media_content_type=mime_type,
title=album_item.file_name,
@@ -197,7 +208,12 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
if identifier.is_shared:
suffix = SHARED_SUFFIX
return PlayMedia(
- f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}{suffix}",
+ (
+ f"/synology_dsm/{identifier.unique_id}/"
+ f"{identifier.cache_key}/"
+ f"{identifier.file_name}{suffix}/"
+ f"{identifier.passphrase}"
+ ),
mime_type,
)
@@ -231,18 +247,24 @@ async def get(
if not self.hass.data.get(DOMAIN):
raise web.HTTPNotFound
# location: {cache_key}/{filename}
- cache_key, file_name = location.split("/")
+ cache_key, file_name, passphrase = location.split("/")
image_id = int(cache_key.split("_")[0])
+
if shared := file_name.endswith(SHARED_SUFFIX):
file_name = file_name.removesuffix(SHARED_SUFFIX)
+
mime_type, _ = mimetypes.guess_type(file_name)
if not isinstance(mime_type, str):
raise web.HTTPNotFound
+
diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id]
assert diskstation.api.photos is not None
- item = SynoPhotosItem(image_id, "", "", "", cache_key, "", shared)
+ item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase)
try:
- image = await diskstation.api.photos.download_item(item)
+ if passphrase:
+ image = await diskstation.api.photos.download_item_thumbnail(item)
+ else:
+ image = await diskstation.api.photos.download_item(item)
except SynologyDSMException as exc:
raise web.HTTPNotFound from exc
return web.Response(body=image, content_type=mime_type)
diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json
index 80527de75cd3aa..e886bcad15014b 100644
--- a/homeassistant/components/system_bridge/manifest.json
+++ b/homeassistant/components/system_bridge/manifest.json
@@ -10,6 +10,6 @@
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
"quality_scale": "silver",
- "requirements": ["systembridgeconnector==4.1.0", "systembridgemodels==4.1.0"],
+ "requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"],
"zeroconf": ["_system-bridge._tcp.local."]
}
diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py
index 3fbc9edec2ad41..4a794a00432f51 100644
--- a/homeassistant/components/systemmonitor/__init__.py
+++ b/homeassistant/components/systemmonitor/__init__.py
@@ -73,7 +73,11 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
- if entry.version == 1:
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if entry.version == 1 and entry.minor_version < 3:
new_options = {**entry.options}
if entry.minor_version == 1:
# Migration copies process sensors to binary sensors
@@ -84,6 +88,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, options=new_options, version=1, minor_version=2
)
+ if entry.minor_version == 2:
+ new_options = {**entry.options}
+ if SENSOR_DOMAIN in new_options:
+ new_options.pop(SENSOR_DOMAIN)
+ hass.config_entries.async_update_entry(
+ entry, options=new_options, version=1, minor_version=3
+ )
+
_LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py
index 0ff882d89da5d2..34b28a1d47a0ce 100644
--- a/homeassistant/components/systemmonitor/config_flow.py
+++ b/homeassistant/components/systemmonitor/config_flow.py
@@ -95,7 +95,7 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
VERSION = 1
- MINOR_VERSION = 2
+ MINOR_VERSION = 3
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
diff --git a/homeassistant/components/systemmonitor/repairs.py b/homeassistant/components/systemmonitor/repairs.py
deleted file mode 100644
index 10b5d18830d701..00000000000000
--- a/homeassistant/components/systemmonitor/repairs.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Repairs platform for the System Monitor integration."""
-
-from __future__ import annotations
-
-from typing import Any, cast
-
-from homeassistant import data_entry_flow
-from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
-from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
-
-
-class ProcessFixFlow(RepairsFlow):
- """Handler for an issue fixing flow."""
-
- def __init__(self, entry: ConfigEntry, processes: list[str]) -> None:
- """Create flow."""
- super().__init__()
- self.entry = entry
- self._processes = processes
-
- async def async_step_init(
- self, user_input: dict[str, str] | None = None
- ) -> data_entry_flow.FlowResult:
- """Handle the first step of a fix flow."""
- return await self.async_step_migrate_process_sensor()
-
- async def async_step_migrate_process_sensor(
- self, user_input: dict[str, Any] | None = None
- ) -> data_entry_flow.FlowResult:
- """Handle the options step of a fix flow."""
- if user_input is None:
- return self.async_show_form(
- step_id="migrate_process_sensor",
- description_placeholders={"processes": ", ".join(self._processes)},
- )
-
- # Migration has copied the sensors to binary sensors
- # Pop the sensors to repair and remove entities
- new_options: dict[str, Any] = self.entry.options.copy()
- new_options.pop(SENSOR_DOMAIN)
-
- entity_reg = er.async_get(self.hass)
- entries = er.async_entries_for_config_entry(entity_reg, self.entry.entry_id)
- for entry in entries:
- if entry.entity_id.startswith("sensor.") and entry.unique_id.startswith(
- "process_"
- ):
- entity_reg.async_remove(entry.entity_id)
-
- self.hass.config_entries.async_update_entry(self.entry, options=new_options)
- await self.hass.config_entries.async_reload(self.entry.entry_id)
- return self.async_create_entry(data={})
-
-
-async def async_create_fix_flow(
- hass: HomeAssistant,
- issue_id: str,
- data: dict[str, Any] | None,
-) -> RepairsFlow:
- """Create flow."""
- entry = None
- if data and (entry_id := data.get("entry_id")):
- entry_id = cast(str, entry_id)
- processes: list[str] = data["processes"]
- entry = hass.config_entries.async_get_entry(entry_id)
- assert entry
- return ProcessFixFlow(entry, processes)
-
- return ConfirmRepairFlow()
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index bad4c3be0b59ad..ef1153f09e827b 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -14,8 +14,6 @@
import time
from typing import Any, Literal
-from psutil import NoSuchProcess
-
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
@@ -25,8 +23,6 @@
)
from homeassistant.const import (
PERCENTAGE,
- STATE_OFF,
- STATE_ON,
EntityCategory,
UnitOfDataRate,
UnitOfInformation,
@@ -36,13 +32,12 @@
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import SystemMonitorConfigEntry
-from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES
+from .const import DOMAIN, NET_IO_TYPES
from .coordinator import SystemMonitorCoordinator
from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature
@@ -68,24 +63,6 @@ def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]:
return "mdi:cpu-32-bit"
-def get_process(entity: SystemMonitorSensor) -> str:
- """Return process."""
- state = STATE_OFF
- for proc in entity.coordinator.data.processes:
- try:
- _LOGGER.debug("process %s for argument %s", proc.name(), entity.argument)
- if entity.argument == proc.name():
- state = STATE_ON
- break
- except NoSuchProcess as err:
- _LOGGER.warning(
- "Failed to load process with ID: %s, old name: %s",
- err.pid,
- err.name,
- )
- return state
-
-
def get_network(entity: SystemMonitorSensor) -> float | None:
"""Return network in and out."""
counters = entity.coordinator.data.io_counters
@@ -341,15 +318,6 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription):
value_fn=get_throughput,
add_to_update=lambda entity: ("io_counters", ""),
),
- "process": SysMonitorSensorEntityDescription(
- key="process",
- translation_key="process",
- placeholder="process",
- icon=get_cpu_icon(),
- mandatory_arg=True,
- value_fn=get_process,
- add_to_update=lambda entity: ("processes", ""),
- ),
"processor_use": SysMonitorSensorEntityDescription(
key="processor_use",
translation_key="processor_use",
@@ -551,35 +519,6 @@ def get_arguments() -> dict[str, Any]:
)
continue
- if _type == "process":
- _entry = entry.options.get(SENSOR_DOMAIN, {})
- for argument in _entry.get(CONF_PROCESS, []):
- loaded_resources.add(slugify(f"{_type}_{argument}"))
- entities.append(
- SystemMonitorSensor(
- coordinator,
- sensor_description,
- entry.entry_id,
- argument,
- True,
- )
- )
- async_create_issue(
- hass,
- DOMAIN,
- "process_sensor",
- breaks_in_ha_version="2024.9.0",
- is_fixable=True,
- is_persistent=False,
- severity=IssueSeverity.WARNING,
- translation_key="process_sensor",
- data={
- "entry_id": entry.entry_id,
- "processes": _entry[CONF_PROCESS],
- },
- )
- continue
-
if _type == "processor_use":
argument = ""
is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources)
diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json
index aae2463c9da7ee..dde97918bc3136 100644
--- a/homeassistant/components/systemmonitor/strings.json
+++ b/homeassistant/components/systemmonitor/strings.json
@@ -22,19 +22,6 @@
}
}
},
- "issues": {
- "process_sensor": {
- "title": "Process sensors are deprecated and will be removed",
- "fix_flow": {
- "step": {
- "migrate_process_sensor": {
- "title": "Process sensors have been setup as binary sensors",
- "description": "Process sensors `{processes}` have been created as binary sensors and the sensors will be removed in 2024.9.0.\n\nPlease update all automations, scripts, dashboards or other things depending on these sensors to use the newly created binary sensors instead and press **Submit** to fix this issue."
- }
- }
- }
- }
- },
"entity": {
"binary_sensor": {
"process": {
diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json
index ab903dafb5b95d..39453cb5fe1c62 100644
--- a/homeassistant/components/tado/strings.json
+++ b/homeassistant/components/tado/strings.json
@@ -152,7 +152,7 @@
"issues": {
"water_heater_fallback": {
"title": "Tado Water Heater entities now support fallback options",
- "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options."
+ "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)."
}
}
}
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
index 97307112f2291f..0462c5bec3488e 100644
--- a/homeassistant/components/tag/__init__.py
+++ b/homeassistant/components/tag/__init__.py
@@ -364,7 +364,6 @@ class TagEntity(Entity):
"""Representation of a Tag entity."""
_unrecorded_attributes = frozenset({TAG_ID})
- _attr_translation_key = DOMAIN
_attr_should_poll = False
def __init__(
diff --git a/homeassistant/components/tag/icons.json b/homeassistant/components/tag/icons.json
index d9532aadf73a8e..c931ae8614c478 100644
--- a/homeassistant/components/tag/icons.json
+++ b/homeassistant/components/tag/icons.json
@@ -1,9 +1,7 @@
{
- "entity": {
- "tag": {
- "tag": {
- "default": "mdi:tag-outline"
- }
+ "entity_component": {
+ "_": {
+ "default": "mdi:tag-outline"
}
}
}
diff --git a/homeassistant/components/tag/manifest.json b/homeassistant/components/tag/manifest.json
index 14701763573822..738e7f7e744a2a 100644
--- a/homeassistant/components/tag/manifest.json
+++ b/homeassistant/components/tag/manifest.json
@@ -3,5 +3,6 @@
"name": "Tags",
"codeowners": ["@balloob", "@dmulcahey"],
"documentation": "https://www.home-assistant.io/integrations/tag",
+ "integration_type": "entity",
"quality_scale": "internal"
}
diff --git a/homeassistant/components/tag/strings.json b/homeassistant/components/tag/strings.json
index 75cec1f9ef471f..4adbf1d48fc497 100644
--- a/homeassistant/components/tag/strings.json
+++ b/homeassistant/components/tag/strings.json
@@ -1,15 +1,13 @@
{
"title": "Tag",
- "entity": {
- "tag": {
- "tag": {
- "state_attributes": {
- "tag_id": {
- "name": "Tag ID"
- },
- "last_scanned_by_device_id": {
- "name": "Last scanned by device ID"
- }
+ "entity_component": {
+ "_": {
+ "state_attributes": {
+ "tag_id": {
+ "name": "Tag ID"
+ },
+ "last_scanned_by_device_id": {
+ "name": "Last scanned by device ID"
}
}
}
diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py
index 8c1edbfb60fd67..72b19470f450c9 100644
--- a/homeassistant/components/tami4/config_flow.py
+++ b/homeassistant/components/tami4/config_flow.py
@@ -42,7 +42,7 @@ async def async_step_user(
if m := _PHONE_MATCHER.match(phone):
self.phone = f"+972{m.group('number')}"
else:
- raise InvalidPhoneNumber
+ raise InvalidPhoneNumber # noqa: TRY301
await self.hass.async_add_executor_job(
Tami4EdgeAPI.request_otp, self.phone
)
@@ -82,8 +82,11 @@ async def async_step_otp(
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
+ device_name = api.device_metadata.name
+ if device_name is None:
+ device_name = "Tami4"
return self.async_create_entry(
- title=api.device_metadata.name,
+ title=device_name,
data={CONF_REFRESH_TOKEN: refresh_token},
)
diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py
index 78bced05b3677e..a500549a64814e 100644
--- a/homeassistant/components/tankerkoenig/__init__.py
+++ b/homeassistant/components/tankerkoenig/__init__.py
@@ -4,15 +4,12 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(
hass: HomeAssistant, entry: TankerkoenigConfigEntry
diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py
index d6a7fb28f11ac1..263fc416026775 100644
--- a/homeassistant/components/tcp/common.py
+++ b/homeassistant/components/tcp/common.py
@@ -25,7 +25,6 @@
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -63,10 +62,6 @@ class TcpEntity(Entity):
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Set all the config values if they exist and get initial state."""
- value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
-
self._hass = hass
self._config: TcpSensorConfig = {
CONF_NAME: config[CONF_NAME],
@@ -75,7 +70,7 @@ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
CONF_TIMEOUT: config[CONF_TIMEOUT],
CONF_PAYLOAD: config[CONF_PAYLOAD],
CONF_UNIT_OF_MEASUREMENT: config.get(CONF_UNIT_OF_MEASUREMENT),
- CONF_VALUE_TEMPLATE: value_template,
+ CONF_VALUE_TEMPLATE: config.get(CONF_VALUE_TEMPLATE),
CONF_VALUE_ON: config.get(CONF_VALUE_ON),
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_SSL: config[CONF_SSL],
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index d11c873a94a4c5..8d5fa028e1216c 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -55,8 +55,13 @@ def __init__(
super().__init__(lock, coordinator, "lock")
@property
- def is_locked(self) -> bool:
+ def is_locked(self) -> bool | None:
"""Return true if lock is locked."""
+ if self._lock.state in (
+ TedeeLockState.HALF_OPEN,
+ TedeeLockState.UNKNOWN,
+ ):
+ return None
return self._lock.state == TedeeLockState.LOCKED
@property
@@ -87,7 +92,11 @@ def is_jammed(self) -> bool:
@property
def available(self) -> bool:
"""Return True if entity is available."""
- return super().available and self._lock.is_connected
+ return (
+ super().available
+ and self._lock.is_connected
+ and self._lock.state != TedeeLockState.UNCALIBRATED
+ )
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the door."""
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index fed9021a46e4d6..9d1a5398055dab 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -408,7 +408,6 @@ def _render_template_attr(data, attribute):
):
data[attribute] = attribute_templ
else:
- attribute_templ.hass = hass
try:
data[attribute] = attribute_templ.async_render(
parse_result=False
diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py
index 2c304f259dadd6..1e27511bd84faa 100644
--- a/homeassistant/components/tellstick/sensor.py
+++ b/homeassistant/components/tellstick/sensor.py
@@ -29,7 +29,7 @@
_LOGGER = logging.getLogger(__name__)
-DatatypeDescription = namedtuple(
+DatatypeDescription = namedtuple( # noqa: PYI024
"DatatypeDescription", ["name", "unit", "device_class"]
)
diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py
index 805f037dbae127..82d8905a775664 100644
--- a/homeassistant/components/telnet/switch.py
+++ b/homeassistant/components/telnet/switch.py
@@ -67,11 +67,6 @@ def setup_platform(
switches = []
for object_id, device_config in devices.items():
- value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE)
-
- if value_template is not None:
- value_template.hass = hass
-
switches.append(
TelnetSwitch(
object_id,
@@ -81,7 +76,7 @@ def setup_platform(
device_config[CONF_COMMAND_ON],
device_config[CONF_COMMAND_OFF],
device_config.get(CONF_COMMAND_STATE),
- value_template,
+ device_config.get(CONF_VALUE_TEMPLATE),
device_config[CONF_TIMEOUT],
)
)
@@ -144,7 +139,7 @@ def update(self) -> None:
rendered = self._value_template.render_with_possible_json_value(response)
else:
_LOGGER.warning("Empty response for command: %s", self._command_state)
- return None
+ return
self._attr_is_on = rendered == "True"
def turn_on(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index 2ac91d39858a62..7c23fdcebcc327 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -108,7 +108,7 @@ async def _async_create_entities(hass, config):
alarm_control_panels = []
for object_id, entity_config in config[CONF_ALARM_CONTROL_PANELS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
alarm_control_panels.append(
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 68b3cd6d35a953..187c7079f5902e 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -119,17 +119,21 @@
)
-def rewrite_legacy_to_modern_conf(cfg: dict[str, dict]) -> list[dict]:
+def rewrite_legacy_to_modern_conf(
+ hass: HomeAssistant, cfg: dict[str, dict]
+) -> list[dict]:
"""Rewrite legacy binary sensor definitions to modern ones."""
sensors = []
for object_id, entity_cfg in cfg.items():
entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id}
- entity_cfg = rewrite_common_legacy_to_modern_conf(entity_cfg, LEGACY_FIELDS)
+ entity_cfg = rewrite_common_legacy_to_modern_conf(
+ hass, entity_cfg, LEGACY_FIELDS
+ )
if CONF_NAME not in entity_cfg:
- entity_cfg[CONF_NAME] = template.Template(object_id)
+ entity_cfg[CONF_NAME] = template.Template(object_id, hass)
sensors.append(entity_cfg)
@@ -183,7 +187,7 @@ async def async_setup_platform(
_async_create_template_tracking_entities(
async_add_entities,
hass,
- rewrite_legacy_to_modern_conf(config[CONF_SENSORS]),
+ rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]),
None,
)
return
diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py
index 42a57cfc4aa18e..e2015743a0eedf 100644
--- a/homeassistant/components/template/config.py
+++ b/homeassistant/components/template/config.py
@@ -115,7 +115,7 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf
)
definitions = list(cfg[new_key]) if new_key in cfg else []
- definitions.extend(transform(cfg[old_key]))
+ definitions.extend(transform(hass, cfg[old_key]))
cfg = {**cfg, new_key: definitions}
config_sections.append(cfg)
diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py
index c52a890c1f792a..2c12a0d03e90ab 100644
--- a/homeassistant/components/template/config_flow.py
+++ b/homeassistant/components/template/config_flow.py
@@ -41,6 +41,16 @@
from .binary_sensor import async_create_preview_binary_sensor
from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
+from .number import (
+ CONF_MAX,
+ CONF_MIN,
+ CONF_SET_VALUE,
+ CONF_STEP,
+ DEFAULT_MAX_VALUE,
+ DEFAULT_MIN_VALUE,
+ DEFAULT_STEP,
+ async_create_preview_number,
+)
from .select import CONF_OPTIONS, CONF_SELECT_OPTION
from .sensor import async_create_preview_sensor
from .switch import async_create_preview_switch
@@ -94,6 +104,21 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(CONF_VERIFY_SSL, default=True): selector.BooleanSelector(),
}
+ if domain == Platform.NUMBER:
+ schema |= {
+ vol.Required(CONF_STATE): selector.TemplateSelector(),
+ vol.Required(
+ CONF_MIN, default=f"{{{{{DEFAULT_MIN_VALUE}}}}}"
+ ): selector.TemplateSelector(),
+ vol.Required(
+ CONF_MAX, default=f"{{{{{DEFAULT_MAX_VALUE}}}}}"
+ ): selector.TemplateSelector(),
+ vol.Required(
+ CONF_STEP, default=f"{{{{{DEFAULT_STEP}}}}}"
+ ): selector.TemplateSelector(),
+ vol.Optional(CONF_SET_VALUE): selector.ActionSelector(),
+ }
+
if domain == Platform.SELECT:
schema |= _SCHEMA_STATE | {
vol.Required(CONF_OPTIONS): selector.TemplateSelector(),
@@ -238,6 +263,7 @@ async def _validate_user_input(
"binary_sensor",
"button",
"image",
+ "number",
"select",
"sensor",
"switch",
@@ -258,6 +284,11 @@ async def _validate_user_input(
config_schema(Platform.IMAGE),
validate_user_input=validate_user_input(Platform.IMAGE),
),
+ Platform.NUMBER: SchemaFlowFormStep(
+ config_schema(Platform.NUMBER),
+ preview="template",
+ validate_user_input=validate_user_input(Platform.NUMBER),
+ ),
Platform.SELECT: SchemaFlowFormStep(
config_schema(Platform.SELECT),
validate_user_input=validate_user_input(Platform.SELECT),
@@ -290,6 +321,11 @@ async def _validate_user_input(
options_schema(Platform.IMAGE),
validate_user_input=validate_user_input(Platform.IMAGE),
),
+ Platform.NUMBER: SchemaFlowFormStep(
+ options_schema(Platform.NUMBER),
+ preview="template",
+ validate_user_input=validate_user_input(Platform.NUMBER),
+ ),
Platform.SELECT: SchemaFlowFormStep(
options_schema(Platform.SELECT),
validate_user_input=validate_user_input(Platform.SELECT),
@@ -311,6 +347,7 @@ async def _validate_user_input(
Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity],
] = {
"binary_sensor": async_create_preview_binary_sensor,
+ "number": async_create_preview_number,
"sensor": async_create_preview_sensor,
"switch": async_create_preview_switch,
}
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index d50067f6278fdf..2c84387ed64fa0 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -106,7 +106,7 @@ async def _async_create_entities(hass, config):
covers = []
for object_id, entity_config in config[CONF_COVERS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index 20a2159e378af6..cedd7d0d72590e 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -94,7 +94,7 @@ async def _async_create_entities(hass, config):
fans = []
for object_id, entity_config in config[CONF_FANS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index ba6b8ce846bed1..cae6c0cebc1d00 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -126,7 +126,7 @@ async def _async_create_entities(hass, config):
lights = []
for object_id, entity_config in config[CONF_LIGHTS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
lights.append(
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 0fa219fcd9b709..5c0b67a23dcf06 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -59,7 +59,7 @@
async def _async_create_entities(hass, config):
"""Create the Template lock."""
- config = rewrite_common_legacy_to_modern_conf(config)
+ config = rewrite_common_legacy_to_modern_conf(hass, config)
return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))]
diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py
index d4004ee95352ed..955600a9b9e079 100644
--- a/homeassistant/components/template/number.py
+++ b/homeassistant/components/template/number.py
@@ -8,9 +8,6 @@
import voluptuous as vol
from homeassistant.components.number import (
- ATTR_MAX,
- ATTR_MIN,
- ATTR_STEP,
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
@@ -18,9 +15,17 @@
DOMAIN as NUMBER_DOMAIN,
NumberEntity,
)
-from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_DEVICE_ID,
+ CONF_NAME,
+ CONF_OPTIMISTIC,
+ CONF_STATE,
+ CONF_UNIQUE_ID,
+)
from homeassistant.core import HomeAssistant, callback
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import config_validation as cv, selector
+from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -37,6 +42,9 @@
_LOGGER = logging.getLogger(__name__)
CONF_SET_VALUE = "set_value"
+CONF_MIN = "min"
+CONF_MAX = "max"
+CONF_STEP = "step"
DEFAULT_NAME = "Template Number"
DEFAULT_OPTIMISTIC = False
@@ -47,9 +55,9 @@
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA,
- vol.Required(ATTR_STEP): cv.template,
- vol.Optional(ATTR_MIN, default=DEFAULT_MIN_VALUE): cv.template,
- vol.Optional(ATTR_MAX, default=DEFAULT_MAX_VALUE): cv.template,
+ vol.Required(CONF_STEP): cv.template,
+ vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template,
+ vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
@@ -57,6 +65,17 @@
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
+NUMBER_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME): cv.template,
+ vol.Required(CONF_STATE): cv.template,
+ vol.Required(CONF_STEP): cv.template,
+ vol.Optional(CONF_SET_VALUE): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_MIN): cv.template,
+ vol.Optional(CONF_MAX): cv.template,
+ vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
+ }
+)
async def _async_create_entities(
@@ -99,6 +118,27 @@ async def async_setup_platform(
)
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Initialize config entry."""
+ _options = dict(config_entry.options)
+ _options.pop("template_type")
+ validated_config = NUMBER_CONFIG_SCHEMA(_options)
+ async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)])
+
+
+@callback
+def async_create_preview_number(
+ hass: HomeAssistant, name: str, config: dict[str, Any]
+) -> TemplateNumber:
+ """Create a preview number."""
+ validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name})
+ return TemplateNumber(hass, validated_config, None)
+
+
class TemplateNumber(TemplateEntity, NumberEntity):
"""Representation of a template number."""
@@ -114,16 +154,22 @@ def __init__(
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
- self._command_set_value = Script(
- hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN
+ self._command_set_value = (
+ Script(hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN)
+ if config.get(CONF_SET_VALUE, None) is not None
+ else None
)
- self._step_template = config[ATTR_STEP]
- self._min_value_template = config[ATTR_MIN]
- self._max_value_template = config[ATTR_MAX]
- self._attr_assumed_state = self._optimistic = config[CONF_OPTIMISTIC]
+ self._step_template = config[CONF_STEP]
+ self._min_value_template = config[CONF_MIN]
+ self._max_value_template = config[CONF_MAX]
+ self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC)
self._attr_native_step = DEFAULT_STEP
self._attr_native_min_value = DEFAULT_MIN_VALUE
self._attr_native_max_value = DEFAULT_MAX_VALUE
+ self._attr_device_info = async_device_info_to_link_from_device_id(
+ hass,
+ config.get(CONF_DEVICE_ID),
+ )
@callback
def _async_setup_templates(self) -> None:
@@ -161,11 +207,12 @@ async def async_set_native_value(self, value: float) -> None:
if self._optimistic:
self._attr_native_value = value
self.async_write_ha_state()
- await self.async_run_script(
- self._command_set_value,
- run_variables={ATTR_VALUE: value},
- context=self._context,
- )
+ if self._command_set_value:
+ await self.async_run_script(
+ self._command_set_value,
+ run_variables={ATTR_VALUE: value},
+ context=self._context,
+ )
class TriggerNumberEntity(TriggerEntity, NumberEntity):
@@ -174,9 +221,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity):
domain = NUMBER_DOMAIN
extra_template_keys = (
CONF_STATE,
- ATTR_STEP,
- ATTR_MIN,
- ATTR_MAX,
+ CONF_STEP,
+ CONF_MIN,
+ CONF_MAX,
)
def __init__(
@@ -203,21 +250,21 @@ def native_value(self) -> float | None:
def native_min_value(self) -> int:
"""Return the minimum value."""
return vol.Any(vol.Coerce(float), None)(
- self._rendered.get(ATTR_MIN, super().native_min_value)
+ self._rendered.get(CONF_MIN, super().native_min_value)
)
@property
def native_max_value(self) -> int:
"""Return the maximum value."""
return vol.Any(vol.Coerce(float), None)(
- self._rendered.get(ATTR_MAX, super().native_max_value)
+ self._rendered.get(CONF_MAX, super().native_max_value)
)
@property
def native_step(self) -> int:
"""Return the increment/decrement step."""
return vol.Any(vol.Coerce(float), None)(
- self._rendered.get(ATTR_STEP, super().native_step)
+ self._rendered.get(CONF_STEP, super().native_step)
)
async def async_set_native_value(self, value: float) -> None:
diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py
index 70a2d5dd650bab..ee24407699d906 100644
--- a/homeassistant/components/template/sensor.py
+++ b/homeassistant/components/template/sensor.py
@@ -142,17 +142,21 @@ def extra_validation_checks(val):
return val
-def rewrite_legacy_to_modern_conf(cfg: dict[str, dict]) -> list[dict]:
+def rewrite_legacy_to_modern_conf(
+ hass: HomeAssistant, cfg: dict[str, dict]
+) -> list[dict]:
"""Rewrite legacy sensor definitions to modern ones."""
sensors = []
for object_id, entity_cfg in cfg.items():
entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id}
- entity_cfg = rewrite_common_legacy_to_modern_conf(entity_cfg, LEGACY_FIELDS)
+ entity_cfg = rewrite_common_legacy_to_modern_conf(
+ hass, entity_cfg, LEGACY_FIELDS
+ )
if CONF_NAME not in entity_cfg:
- entity_cfg[CONF_NAME] = template.Template(object_id)
+ entity_cfg[CONF_NAME] = template.Template(object_id, hass)
sensors.append(entity_cfg)
@@ -210,7 +214,7 @@ async def async_setup_platform(
_async_create_template_tracking_entities(
async_add_entities,
hass,
- rewrite_legacy_to_modern_conf(config[CONF_SENSORS]),
+ rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]),
None,
)
return
diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json
index b1f14af2202234..fa365bf3cfdfc6 100644
--- a/homeassistant/components/template/strings.json
+++ b/homeassistant/components/template/strings.json
@@ -37,6 +37,21 @@
},
"title": "Template image"
},
+ "number": {
+ "data": {
+ "device_id": "[%key:common::config_flow::data::device%]",
+ "name": "[%key:common::config_flow::data::name%]",
+ "state": "[%key:component::template::config::step::sensor::data::state%]",
+ "step": "Step value",
+ "set_value": "Actions on set value",
+ "max": "Maximum value",
+ "min": "Minimum value"
+ },
+ "data_description": {
+ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]"
+ },
+ "title": "Template number"
+ },
"select": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@@ -70,6 +85,7 @@
"binary_sensor": "Template a binary sensor",
"button": "Template a button",
"image": "Template a image",
+ "number": "Template a number",
"select": "Template a select",
"sensor": "Template a sensor",
"switch": "Template a switch"
@@ -125,6 +141,21 @@
},
"title": "[%key:component::template::config::step::image::title%]"
},
+ "number": {
+ "data": {
+ "device_id": "[%key:common::config_flow::data::device%]",
+ "name": "[%key:common::config_flow::data::name%]",
+ "state": "[%key:component::template::config::step::sensor::data::state%]",
+ "step": "[%key:component::template::config::step::number::data::step%]",
+ "set_value": "[%key:component::template::config::step::number::data::set_value%]",
+ "max": "[%key:component::template::config::step::number::data::max%]",
+ "min": "[%key:component::template::config::step::number::data::min%]"
+ },
+ "data_description": {
+ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]"
+ },
+ "title": "[%key:component::template::config::step::number::title%]"
+ },
"select": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
index fbb35399ef80b2..9145625f706d1e 100644
--- a/homeassistant/components/template/switch.py
+++ b/homeassistant/components/template/switch.py
@@ -76,7 +76,7 @@ async def _async_create_entities(hass, config):
switches = []
for object_id, entity_config in config[CONF_SWITCHES].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
switches.append(
diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py
index b5d2ab6fff3dd0..a074f82828460f 100644
--- a/homeassistant/components/template/template_entity.py
+++ b/homeassistant/components/template/template_entity.py
@@ -123,7 +123,9 @@ def make_template_entity_common_schema(default_name: str) -> vol.Schema:
def rewrite_common_legacy_to_modern_conf(
- entity_cfg: dict[str, Any], extra_legacy_fields: dict[str, str] | None = None
+ hass: HomeAssistant,
+ entity_cfg: dict[str, Any],
+ extra_legacy_fields: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Rewrite legacy config."""
entity_cfg = {**entity_cfg}
@@ -138,11 +140,11 @@ def rewrite_common_legacy_to_modern_conf(
val = entity_cfg.pop(from_key)
if isinstance(val, str):
- val = Template(val)
+ val = Template(val, hass)
entity_cfg[to_key] = val
if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str):
- entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME])
+ entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass)
return entity_cfg
@@ -310,7 +312,6 @@ def name(self) -> str:
# Try to render the name as it can influence the entity ID
self._attr_name = fallback_name
if self._friendly_name_template:
- self._friendly_name_template.hass = hass
with contextlib.suppress(TemplateError):
self._attr_name = self._friendly_name_template.async_render(
variables=variables, parse_result=False
@@ -319,14 +320,12 @@ def name(self) -> str:
# Templates will not render while the entity is unavailable, try to render the
# icon and picture templates.
if self._entity_picture_template:
- self._entity_picture_template.hass = hass
with contextlib.suppress(TemplateError):
self._attr_entity_picture = self._entity_picture_template.async_render(
variables=variables, parse_result=False
)
if self._icon_template:
- self._icon_template.hass = hass
with contextlib.suppress(TemplateError):
self._attr_icon = self._icon_template.async_render(
variables=variables, parse_result=False
@@ -388,8 +387,10 @@ def add_template_attribute(
If True, the attribute will be set to None if the template errors.
"""
- assert self.hass is not None, "hass cannot be None"
- template.hass = self.hass
+ if self.hass is None:
+ raise ValueError("hass cannot be None")
+ if template.hass is None:
+ raise ValueError("template.hass cannot be None")
template_attribute = _TemplateAttribute(
self, attribute, template, validator, on_update, none_on_template_error
)
diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py
index 09ad07546348dd..44ac2d930517de 100644
--- a/homeassistant/components/template/trigger.py
+++ b/homeassistant/components/template/trigger.py
@@ -49,9 +49,7 @@ async def async_attach_trigger(
"""Listen for state changes based on configuration."""
trigger_data = trigger_info["trigger_data"]
value_template: Template = config[CONF_VALUE_TEMPLATE]
- value_template.hass = hass
time_delta = config.get(CONF_FOR)
- template.attach(hass, time_delta)
delay_cancel = None
job = HassJob(action)
armed = False
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index 9062f71d818bec..1d021bcb571c05 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -100,7 +100,7 @@ async def _async_create_entities(hass, config):
vacuums = []
for object_id, entity_config in config[CONF_VACUUMS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(entity_config)
+ entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
unique_id = entity_config.get(CONF_UNIQUE_ID)
vacuums.append(
@@ -318,7 +318,7 @@ def _update_battery_level(self, battery_level):
try:
battery_level_int = int(battery_level)
if not 0 <= battery_level_int <= 100:
- raise ValueError
+ raise ValueError # noqa: TRY301
except ValueError:
_LOGGER.error(
"Received invalid battery level: %s for entity %s. Expected: 0-100",
diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py
index 5c3e4107b2c05e..ec6d1f08dd3a18 100644
--- a/homeassistant/components/template/weather.py
+++ b/homeassistant/components/template/weather.py
@@ -153,7 +153,7 @@ async def async_setup_platform(
)
return
- config = rewrite_common_legacy_to_modern_conf(config)
+ config = rewrite_common_legacy_to_modern_conf(hass, config)
unique_id = config.get(CONF_UNIQUE_ID)
async_add_entities(
diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py
index 85fe6439f1ca39..f13c0b24d0b611 100644
--- a/homeassistant/components/tensorflow/image_processing.py
+++ b/homeassistant/components/tensorflow/image_processing.py
@@ -261,8 +261,6 @@ def __init__(
area_config.get(CONF_RIGHT),
]
- template.attach(hass, self._file_out)
-
self._matches = {}
self._total_matches = 0
self._last_image = None
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index 2c5ee1b5c75e57..47a2a9173a5d09 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -7,7 +7,9 @@
from tesla_fleet_api import EnergySpecific, TeslaFleetApi, VehicleSpecific
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
+ InvalidRegion,
InvalidToken,
+ LibraryError,
LoginRequired,
OAuthExpired,
TeslaFleetError,
@@ -26,6 +28,7 @@
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
+from .config_flow import OAuth2FlowHandler
from .const import DOMAIN, LOGGER, MODELS
from .coordinator import (
TeslaFleetEnergySiteInfoCoordinator,
@@ -33,6 +36,7 @@
TeslaFleetVehicleDataCoordinator,
)
from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData
+from .oauth import TeslaSystemImplementation
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR]
@@ -51,6 +55,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
scopes = token["scp"]
region = token["ou_code"].lower()
+ OAuth2FlowHandler.async_register_implementation(
+ hass,
+ TeslaSystemImplementation(hass),
+ )
+
implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = OAuth2Session(hass, entry, implementation)
refresh_lock = asyncio.Lock()
@@ -68,7 +77,6 @@ async def _refresh_token() -> str:
region=region,
charging_scope=False,
partner_scope=False,
- user_scope=False,
energy_scope=Scope.ENERGY_DEVICE_DATA in scopes,
vehicle_scope=Scope.VEHICLE_DEVICE_DATA in scopes,
refresh_hook=_refresh_token,
@@ -77,6 +85,16 @@ async def _refresh_token() -> str:
products = (await tesla.products())["response"]
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
+ except InvalidRegion:
+ try:
+ LOGGER.info("Region is invalid, trying to find the correct region")
+ await tesla.find_server()
+ try:
+ products = (await tesla.products())["response"]
+ except TeslaFleetError as e:
+ raise ConfigEntryNotReady from e
+ except LibraryError as e:
+ raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
@@ -86,7 +104,7 @@ async def _refresh_token() -> str:
vehicles: list[TeslaFleetVehicleData] = []
energysites: list[TeslaFleetEnergyData] = []
for product in products:
- if "vin" in product and tesla.vehicle:
+ if "vin" in product and hasattr(tesla, "vehicle"):
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
@@ -111,7 +129,7 @@ async def _refresh_token() -> str:
device=device,
)
)
- elif "energy_site_id" in product and tesla.energy:
+ elif "energy_site_id" in product and hasattr(tesla, "energy"):
site_id = product["energy_site_id"]
if not (
product["components"]["battery"]
diff --git a/homeassistant/components/tesla_fleet/application_credentials.py b/homeassistant/components/tesla_fleet/application_credentials.py
index fda9fce8cecf42..0ef38567b65c69 100644
--- a/homeassistant/components/tesla_fleet/application_credentials.py
+++ b/homeassistant/components/tesla_fleet/application_credentials.py
@@ -1,71 +1,18 @@
"""Application Credentials platform the Tesla Fleet integration."""
-import base64
-import hashlib
-import secrets
-from typing import Any
-
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
-from .const import DOMAIN, SCOPES
-
-CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d"
-AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize"
-TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token"
+from .oauth import TeslaUserImplementation
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return auth implementation."""
- return TeslaOAuth2Implementation(
+ return TeslaUserImplementation(
hass,
- DOMAIN,
+ auth_domain,
+ credential,
)
-
-
-class TeslaOAuth2Implementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
- """Tesla Fleet API Open Source Oauth2 implementation."""
-
- _name = "Tesla Fleet API"
-
- def __init__(self, hass: HomeAssistant, domain: str) -> None:
- """Initialize local auth implementation."""
- self.hass = hass
- self._domain = domain
-
- # Setup PKCE
- self.code_verifier = secrets.token_urlsafe(32)
- hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest()
- self.code_challenge = (
- base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "")
- )
- super().__init__(
- hass,
- domain,
- CLIENT_ID,
- "", # Implementation has no client secret
- AUTHORIZE_URL,
- TOKEN_URL,
- )
-
- @property
- def extra_authorize_data(self) -> dict[str, Any]:
- """Extra data that needs to be appended to the authorize url."""
- return {
- "scope": " ".join(SCOPES),
- "code_challenge": self.code_challenge, # PKCE
- }
-
- async def async_resolve_external_data(self, external_data: Any) -> dict:
- """Resolve the authorization code to tokens."""
- return await self._token_request(
- {
- "grant_type": "authorization_code",
- "code": external_data["code"],
- "redirect_uri": external_data["state"]["redirect_uri"],
- "code_verifier": self.code_verifier, # PKCE
- }
- )
diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py
index ad6ba8817c91dd..0ffdca1aec601a 100644
--- a/homeassistant/components/tesla_fleet/config_flow.py
+++ b/homeassistant/components/tesla_fleet/config_flow.py
@@ -12,6 +12,7 @@
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, LOGGER
+from .oauth import TeslaSystemImplementation
class OAuth2FlowHandler(
@@ -27,6 +28,17 @@ def logger(self) -> logging.Logger:
"""Return logger."""
return LOGGER
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow start."""
+ self.async_register_implementation(
+ self.hass,
+ TeslaSystemImplementation(self.hass),
+ )
+
+ return await super().async_step_user()
+
async def async_oauth_create_entry(
self,
data: dict[str, Any],
diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py
index ae622d2266cece..081225c296cf9c 100644
--- a/homeassistant/components/tesla_fleet/const.py
+++ b/homeassistant/components/tesla_fleet/const.py
@@ -13,6 +13,10 @@
LOGGER = logging.getLogger(__package__)
+CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d"
+AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize"
+TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token"
+
SCOPES = [
Scope.OPENID,
Scope.OFFLINE_ACCESS,
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 2acacab5065fcb..29966b3b49c7cb 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
+ "quality_scale": "gold",
"requirements": ["tesla-fleet-api==0.7.3"]
}
diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py
new file mode 100644
index 00000000000000..00976abf56fd6b
--- /dev/null
+++ b/homeassistant/components/tesla_fleet/oauth.py
@@ -0,0 +1,86 @@
+"""Provide oauth implementations for the Tesla Fleet integration."""
+
+import base64
+import hashlib
+import secrets
+from typing import Any
+
+from homeassistant.components.application_credentials import (
+ AuthImplementation,
+ AuthorizationServer,
+ ClientCredential,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_entry_oauth2_flow
+
+from .const import AUTHORIZE_URL, CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL
+
+
+class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
+ """Tesla Fleet API open source Oauth2 implementation."""
+
+ code_verifier: str
+ code_challenge: str
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize open source Oauth2 implementation."""
+
+ # Setup PKCE
+ self.code_verifier = secrets.token_urlsafe(32)
+ hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest()
+ self.code_challenge = (
+ base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "")
+ )
+ super().__init__(
+ hass,
+ DOMAIN,
+ CLIENT_ID,
+ "",
+ AUTHORIZE_URL,
+ TOKEN_URL,
+ )
+
+ @property
+ def name(self) -> str:
+ """Name of the implementation."""
+ return "Built-in open source client ID"
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {
+ "scope": " ".join(SCOPES),
+ "code_challenge": self.code_challenge, # PKCE
+ }
+
+ async def async_resolve_external_data(self, external_data: Any) -> dict:
+ """Resolve the authorization code to tokens."""
+ return await self._token_request(
+ {
+ "grant_type": "authorization_code",
+ "code": external_data["code"],
+ "redirect_uri": external_data["state"]["redirect_uri"],
+ "code_verifier": self.code_verifier, # PKCE
+ }
+ )
+
+
+class TeslaUserImplementation(AuthImplementation):
+ """Tesla Fleet API user Oauth2 implementation."""
+
+ def __init__(
+ self, hass: HomeAssistant, auth_domain: str, credential: ClientCredential
+ ) -> None:
+ """Initialize user Oauth2 implementation."""
+
+ super().__init__(
+ hass,
+ auth_domain,
+ credential,
+ AuthorizationServer(AUTHORIZE_URL, TOKEN_URL),
+ )
+
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {"scope": " ".join(SCOPES)}
diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py
index 93ce25993d9409..e739f8c074de4a 100644
--- a/homeassistant/components/tessie/cover.py
+++ b/homeassistant/components/tessie/cover.py
@@ -168,13 +168,13 @@ def is_closed(self) -> bool | None:
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open rear trunk."""
- if self._value == TessieCoverStates.CLOSED:
+ if self.is_closed:
await self.run(open_close_rear_trunk)
self.set((self.key, TessieCoverStates.OPEN))
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close rear trunk."""
- if self._value == TessieCoverStates.OPEN:
+ if not self.is_closed:
await self.run(open_close_rear_trunk)
self.set((self.key, TessieCoverStates.CLOSED))
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 6059072c239324..c921921a0ca983 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -5,7 +5,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
- "loggers": ["tessie"],
+ "loggers": ["tessie", "tesla-fleet-api"],
"quality_scale": "platinum",
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.7.3"]
}
diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py
index 442442f0e1d64d..245d10bebba61c 100644
--- a/homeassistant/components/time_date/sensor.py
+++ b/homeassistant/components/time_date/sensor.py
@@ -48,7 +48,7 @@ async def async_setup_platform(
"""Set up the Time and Date sensor."""
if hass.config.time_zone is None:
_LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable]
- return False
+ return
async_add_entities(
[TimeDateSensor(variable) for variable in config[CONF_DISPLAY_OPTIONS]]
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index 3f2b4bd7f43c6d..c2057551239348 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -159,9 +159,9 @@ async def reload_service_handler(service_call: ServiceCall) -> None:
{vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
"async_start",
)
- component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause")
- component.async_register_entity_service(SERVICE_CANCEL, {}, "async_cancel")
- component.async_register_entity_service(SERVICE_FINISH, {}, "async_finish")
+ component.async_register_entity_service(SERVICE_PAUSE, None, "async_pause")
+ component.async_register_entity_service(SERVICE_CANCEL, None, "async_cancel")
+ component.async_register_entity_service(SERVICE_FINISH, None, "async_finish")
component.async_register_entity_service(
SERVICE_CHANGE,
{vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py
index 5febc9561c4eb3..d35d9d6bbea8a9 100644
--- a/homeassistant/components/todo/__init__.py
+++ b/homeassistant/components/todo/__init__.py
@@ -183,7 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
component.async_register_entity_service(
TodoServices.REMOVE_COMPLETED_ITEMS,
- {},
+ None,
_async_remove_completed_items,
required_features=[TodoListEntityFeature.DELETE_TODO_ITEM],
)
diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py
index 50afe916b271d5..cd8ad7f02abb41 100644
--- a/homeassistant/components/todo/intent.py
+++ b/homeassistant/components/todo/intent.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import voluptuous as vol
+
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers.entity_component import EntityComponent
@@ -21,7 +23,10 @@ class ListAddItemIntent(intent.IntentHandler):
intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list"
- slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string}
+ slot_schema = {
+ vol.Required("item"): intent.non_empty_string,
+ vol.Required("name"): intent.non_empty_string,
+ }
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 1c6f40005c1642..2acd4ea6dc6df0 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -21,7 +21,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -54,6 +54,7 @@
REMINDER_DATE,
REMINDER_DATE_LANG,
REMINDER_DATE_STRING,
+ SECTION_NAME,
SERVICE_NEW_TASK,
START,
SUMMARY,
@@ -68,6 +69,7 @@
vol.Required(CONTENT): cv.string,
vol.Optional(DESCRIPTION): cv.string,
vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower),
+ vol.Optional(SECTION_NAME): vol.All(cv.string, vol.Lower),
vol.Optional(LABELS): cv.ensure_list_csv,
vol.Optional(ASSIGNEE): cv.string,
vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
@@ -201,7 +203,7 @@ async def _shutdown_coordinator(_: Event) -> None:
async_register_services(hass, coordinator)
-def async_register_services(
+def async_register_services( # noqa: C901
hass: HomeAssistant, coordinator: TodoistCoordinator
) -> None:
"""Register services."""
@@ -211,16 +213,42 @@ def async_register_services(
session = async_get_clientsession(hass)
- async def handle_new_task(call: ServiceCall) -> None:
+ async def handle_new_task(call: ServiceCall) -> None: # noqa: C901
"""Call when a user creates a new Todoist Task from Home Assistant."""
- project_name = call.data[PROJECT_NAME].lower()
+ project_name = call.data[PROJECT_NAME]
projects = await coordinator.async_get_projects()
project_id: str | None = None
for project in projects:
if project_name == project.name.lower():
project_id = project.id
+ break
if project_id is None:
- raise HomeAssistantError(f"Invalid project name '{project_name}'")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="project_invalid",
+ translation_placeholders={
+ "project": project_name,
+ },
+ )
+
+ # Optional section within project
+ section_id: str | None = None
+ if SECTION_NAME in call.data:
+ section_name = call.data[SECTION_NAME]
+ sections = await coordinator.async_get_sections(project_id)
+ for section in sections:
+ if section_name == section.name.lower():
+ section_id = section.id
+ break
+ if section_id is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="section_invalid",
+ translation_placeholders={
+ "section": section_name,
+ "project": project_name,
+ },
+ )
# Create the task
content = call.data[CONTENT]
@@ -228,6 +256,10 @@ async def handle_new_task(call: ServiceCall) -> None:
if description := call.data.get(DESCRIPTION):
data["description"] = description
+
+ if section_id is not None:
+ data["section_id"] = section_id
+
if task_labels := call.data.get(LABELS):
data["labels"] = task_labels
diff --git a/homeassistant/components/todoist/const.py b/homeassistant/components/todoist/const.py
index 1a66fc9764f1b9..be95d57dd2c130 100644
--- a/homeassistant/components/todoist/const.py
+++ b/homeassistant/components/todoist/const.py
@@ -78,6 +78,8 @@
PROJECT_NAME: Final = "project"
# Todoist API: Fetch all Projects
PROJECTS: Final = "projects"
+# Section Name: What Section of the Project do you want to add the Task to?
+SECTION_NAME: Final = "section"
# Calendar Platform: When does a calendar event start?
START: Final = "start"
# Calendar Platform: What is the next calendar event about?
diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py
index e01b4ecb35a626..b55680907ac585 100644
--- a/homeassistant/components/todoist/coordinator.py
+++ b/homeassistant/components/todoist/coordinator.py
@@ -4,7 +4,7 @@
import logging
from todoist_api_python.api_async import TodoistAPIAsync
-from todoist_api_python.models import Label, Project, Task
+from todoist_api_python.models import Label, Project, Section, Task
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -41,6 +41,10 @@ async def async_get_projects(self) -> list[Project]:
self._projects = await self.api.get_projects()
return self._projects
+ async def async_get_sections(self, project_id: str) -> list[Section]:
+ """Return todoist sections for a given project ID."""
+ return await self.api.get_sections(project_id=project_id)
+
async def async_get_labels(self) -> list[Label]:
"""Return todoist labels fetched at most once."""
if self._labels is None:
diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml
index 1bd6320ebe3c21..17d877ea786a15 100644
--- a/homeassistant/components/todoist/services.yaml
+++ b/homeassistant/components/todoist/services.yaml
@@ -13,6 +13,10 @@ new_task:
default: Inbox
selector:
text:
+ section:
+ example: Deliveries
+ selector:
+ text:
labels:
example: Chores,Delivieries
selector:
diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json
index 0cc74c9c8c61d7..5b083ac58bfe80 100644
--- a/homeassistant/components/todoist/strings.json
+++ b/homeassistant/components/todoist/strings.json
@@ -20,6 +20,14 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
+ "exceptions": {
+ "project_invalid": {
+ "message": "Invalid project name \"{project}\""
+ },
+ "section_invalid": {
+ "message": "Project \"{project}\" has no section \"{section}\""
+ }
+ },
"services": {
"new_task": {
"name": "New task",
@@ -37,6 +45,10 @@
"name": "Project",
"description": "The name of the project this task should belong to."
},
+ "section": {
+ "name": "Section",
+ "description": "The name of a section within the project to add the task to."
+ },
"labels": {
"name": "Labels",
"description": "Any labels that you want to apply to this task, separated by a comma."
diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py
index bb19697b1e7de8..0d8b915770a108 100644
--- a/homeassistant/components/totalconnect/__init__.py
+++ b/homeassistant/components/totalconnect/__init__.py
@@ -7,15 +7,12 @@
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-import homeassistant.helpers.config_validation as cv
from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN
from .coordinator import TotalConnectDataUpdateCoordinator
PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up upon config entry in user interface."""
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
index 17a16674dd563e..edbbbb06e707f5 100644
--- a/homeassistant/components/totalconnect/alarm_control_panel.py
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -55,13 +55,13 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_ALARM_ARM_AWAY_INSTANT,
- {},
+ None,
"async_alarm_arm_away_instant",
)
platform.async_register_entity_service(
SERVICE_ALARM_ARM_HOME_INSTANT,
- {},
+ None,
"async_alarm_arm_home_instant",
)
@@ -103,6 +103,7 @@ def __init__(
@property
def state(self) -> str | None:
"""Return the state of the device."""
+ # State attributes can be removed in 2025.3
attr = {
"location_id": self._location.location_id,
"partition": self._partition_id,
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 9b7dd499c97e25..8d6ec27f81c55b 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -382,17 +382,25 @@ def _async_update_attrs(self) -> None:
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness, transition = self._async_extract_brightness_transition(**kwargs)
- if (
- (effect := kwargs.get(ATTR_EFFECT))
- # Effect is unlikely to be LIGHT_EFFECTS_OFF but check for it anyway
- and effect not in {LightEffect.LIGHT_EFFECTS_OFF, EFFECT_OFF}
- and effect in self._effect_module.effect_list
- ):
- await self._effect_module.set_effect(
- kwargs[ATTR_EFFECT], brightness=brightness, transition=transition
- )
- elif ATTR_COLOR_TEMP_KELVIN in kwargs:
- if self.effect and self.effect != EFFECT_OFF:
+ effect_off_called = False
+ if effect := kwargs.get(ATTR_EFFECT):
+ if effect in {LightEffect.LIGHT_EFFECTS_OFF, EFFECT_OFF}:
+ if self._effect_module.effect is not LightEffect.LIGHT_EFFECTS_OFF:
+ await self._effect_module.set_effect(LightEffect.LIGHT_EFFECTS_OFF)
+ effect_off_called = True
+ if len(kwargs) == 1:
+ return
+ elif effect in self._effect_module.effect_list:
+ await self._effect_module.set_effect(
+ kwargs[ATTR_EFFECT], brightness=brightness, transition=transition
+ )
+ return
+ else:
+ _LOGGER.error("Invalid effect %s for %s", effect, self._device.host)
+ return
+
+ if ATTR_COLOR_TEMP_KELVIN in kwargs:
+ if self.effect and self.effect != EFFECT_OFF and not effect_off_called:
# If there is an effect in progress
# we have to clear the effect
# before we can set a color temp
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
index f935d019541c21..10b0ef61153b78 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -301,5 +301,5 @@
"iot_class": "local_polling",
"loggers": ["kasa"],
"quality_scale": "platinum",
- "requirements": ["python-kasa[speedups]==0.7.0.5"]
+ "requirements": ["python-kasa[speedups]==0.7.1"]
}
diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py
index 2e267ffaa1445a..0060310e6c2bb5 100644
--- a/homeassistant/components/tradfri/__init__.py
+++ b/homeassistant/components/tradfri/__init__.py
@@ -14,7 +14,6 @@
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -35,7 +34,6 @@
)
from .coordinator import TradfriDeviceDataUpdateCoordinator
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [
Platform.COVER,
Platform.FAN,
diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py
index 4ad1424aa9afcb..20695f2650091a 100644
--- a/homeassistant/components/tradfri/switch.py
+++ b/homeassistant/components/tradfri/switch.py
@@ -73,11 +73,11 @@ def is_on(self) -> bool:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the switch to turn off."""
if not self._device_control:
- return None
+ return
await self._api(self._device_control.set_state(False))
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the switch to turn on."""
if not self._device_control:
- return None
+ return
await self._api(self._device_control.set_state(True))
diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py
index 4bf1f6818078dc..3e807df9301408 100644
--- a/homeassistant/components/trafikverket_train/__init__.py
+++ b/homeassistant/components/trafikverket_train/__init__.py
@@ -2,21 +2,11 @@
from __future__ import annotations
-from pytrafikverket import TrafikverketTrain
-from pytrafikverket.exceptions import (
- InvalidAuthentication,
- MultipleTrainStationsFound,
- NoTrainStationFound,
-)
-
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CONF_FROM, CONF_TO, PLATFORMS
+from .const import PLATFORMS
from .coordinator import TVDataUpdateCoordinator
TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
@@ -25,21 +15,7 @@
async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
"""Set up Trafikverket Train from a config entry."""
- http_session = async_get_clientsession(hass)
- train_api = TrafikverketTrain(http_session, entry.data[CONF_API_KEY])
-
- try:
- to_station = await train_api.async_get_train_station(entry.data[CONF_TO])
- from_station = await train_api.async_get_train_station(entry.data[CONF_FROM])
- except InvalidAuthentication as error:
- raise ConfigEntryAuthFailed from error
- except (NoTrainStationFound, MultipleTrainStationsFound) as error:
- raise ConfigEntryNotReady(
- f"Problem when trying station {entry.data[CONF_FROM]} to"
- f" {entry.data[CONF_TO]}. Error: {error} "
- ) from error
-
- coordinator = TVDataUpdateCoordinator(hass, to_station, from_station)
+ coordinator = TVDataUpdateCoordinator(hass)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py
index 66ef3e6a1d24fc..16a7a649b85a73 100644
--- a/homeassistant/components/trafikverket_train/coordinator.py
+++ b/homeassistant/components/trafikverket_train/coordinator.py
@@ -10,7 +10,9 @@
from pytrafikverket import TrafikverketTrain
from pytrafikverket.exceptions import (
InvalidAuthentication,
+ MultipleTrainStationsFound,
NoTrainAnnouncementFound,
+ NoTrainStationFound,
UnknownError,
)
from pytrafikverket.models import StationInfoModel, TrainStopModel
@@ -22,7 +24,7 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
-from .const import CONF_FILTER_PRODUCT, CONF_TIME, DOMAIN
+from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
from .util import next_departuredate
if TYPE_CHECKING:
@@ -69,13 +71,10 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]):
"""A Trafikverket Data Update Coordinator."""
config_entry: TVTrainConfigEntry
+ from_station: StationInfoModel
+ to_station: StationInfoModel
- def __init__(
- self,
- hass: HomeAssistant,
- to_station: StationInfoModel,
- from_station: StationInfoModel,
- ) -> None:
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the Trafikverket coordinator."""
super().__init__(
hass,
@@ -86,14 +85,29 @@ def __init__(
self._train_api = TrafikverketTrain(
async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY]
)
- self.from_station: StationInfoModel = from_station
- self.to_station: StationInfoModel = to_station
self._time: time | None = dt_util.parse_time(self.config_entry.data[CONF_TIME])
self._weekdays: list[str] = self.config_entry.data[CONF_WEEKDAY]
self._filter_product: str | None = self.config_entry.options.get(
CONF_FILTER_PRODUCT
)
+ async def _async_setup(self) -> None:
+ """Initiate stations."""
+ try:
+ self.to_station = await self._train_api.async_get_train_station(
+ self.config_entry.data[CONF_TO]
+ )
+ self.from_station = await self._train_api.async_get_train_station(
+ self.config_entry.data[CONF_FROM]
+ )
+ except InvalidAuthentication as error:
+ raise ConfigEntryAuthFailed from error
+ except (NoTrainStationFound, MultipleTrainStationsFound) as error:
+ raise UpdateFailed(
+ f"Problem when trying station {self.config_entry.data[CONF_FROM]} to"
+ f" {self.config_entry.data[CONF_TO]}. Error: {error} "
+ ) from error
+
async def _async_update_data(self) -> TrainData:
"""Fetch data from Trafikverket."""
diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py
index d6b5b695656ec5..e0930bd9e9eaad 100644
--- a/homeassistant/components/transmission/coordinator.py
+++ b/homeassistant/components/transmission/coordinator.py
@@ -55,12 +55,12 @@ def __init__(
@property
def limit(self) -> int:
"""Return limit."""
- return self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT)
+ return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT)
@property
def order(self) -> str:
"""Return order."""
- return self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER)
+ return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER)
async def _async_update_data(self) -> SessionStats:
"""Update transmission data."""
diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py
index 787f3298e5915f..5628274b967240 100644
--- a/homeassistant/components/transport_nsw/sensor.py
+++ b/homeassistant/components/transport_nsw/sensor.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import timedelta
+from typing import Any
from TransportNSW import TransportNSW
import voluptuous as vol
@@ -98,7 +99,7 @@ def native_value(self):
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self._times is not None:
return {
@@ -110,6 +111,7 @@ def extra_state_attributes(self):
ATTR_DESTINATION: self._times[ATTR_DESTINATION],
ATTR_MODE: self._times[ATTR_MODE],
}
+ return None
@property
def native_unit_of_measurement(self):
diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py
index 66b076126b5e36..eef51ca9613f2c 100644
--- a/homeassistant/components/twitter/notify.py
+++ b/homeassistant/components/twitter/notify.py
@@ -129,10 +129,11 @@ def send_message_callback(self, message, user, media_id=None):
else:
_LOGGER.debug("Message posted: %s", resp.json())
- def upload_media_then_callback(self, callback, media_path=None):
+ def upload_media_then_callback(self, callback, media_path=None) -> None:
"""Upload media."""
if not media_path:
- return callback()
+ callback()
+ return
with open(media_path, "rb") as file:
total_bytes = os.path.getsize(media_path)
@@ -141,7 +142,7 @@ def upload_media_then_callback(self, callback, media_path=None):
if 199 > resp.status_code < 300:
self.log_error_resp(resp)
- return None
+ return
media_id = resp.json()["media_id"]
media_id = self.upload_media_chunked(file, total_bytes, media_id)
@@ -149,10 +150,11 @@ def upload_media_then_callback(self, callback, media_path=None):
resp = self.upload_media_finalize(media_id)
if 199 > resp.status_code < 300:
self.log_error_resp(resp)
- return None
+ return
if resp.json().get("processing_info") is None:
- return callback(media_id)
+ callback(media_id)
+ return
self.check_status_until_done(media_id, callback)
@@ -209,7 +211,7 @@ def upload_media_finalize(self, media_id):
"media/upload", {"command": "FINALIZE", "media_id": media_id}
)
- def check_status_until_done(self, media_id, callback, *args):
+ def check_status_until_done(self, media_id, callback, *args) -> None:
"""Upload media, STATUS phase."""
resp = self.api.request(
"media/upload",
@@ -223,7 +225,8 @@ def check_status_until_done(self, media_id, callback, *args):
_LOGGER.debug("media processing %s status: %s", media_id, processing_info)
if processing_info["state"] in {"succeeded", "failed"}:
- return callback(media_id)
+ callback(media_id)
+ return
check_after_secs = processing_info["check_after_secs"]
_LOGGER.debug(
diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py
index 8e874be0bca2f5..a86f7a1cc832c8 100644
--- a/homeassistant/components/uk_transport/sensor.py
+++ b/homeassistant/components/uk_transport/sensor.py
@@ -6,6 +6,7 @@
from http import HTTPStatus
import logging
import re
+from typing import Any
import requests
import voluptuous as vol
@@ -196,10 +197,10 @@ def _update(self):
self._state = None
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return other details about the sensor state."""
- attrs = {}
if self._data is not None:
+ attrs = {ATTR_NEXT_BUSES: self._next_buses}
for key in (
ATTR_ATCOCODE,
ATTR_LOCALITY,
@@ -207,8 +208,8 @@ def extra_state_attributes(self):
ATTR_REQUEST_TIME,
):
attrs[key] = self._data.get(key)
- attrs[ATTR_NEXT_BUSES] = self._next_buses
return attrs
+ return None
class UkTransportLiveTrainTimeSensor(UkTransportSensor):
@@ -266,15 +267,17 @@ def _update(self):
self._state = None
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return other details about the sensor state."""
- attrs = {}
if self._data is not None:
- attrs[ATTR_STATION_CODE] = self._station_code
- attrs[ATTR_CALLING_AT] = self._calling_at
+ attrs = {
+ ATTR_STATION_CODE: self._station_code,
+ ATTR_CALLING_AT: self._calling_at,
+ }
if self._next_trains:
attrs[ATTR_NEXT_TRAINS] = self._next_trains
return attrs
+ return None
def _delta_mins(hhmm_time_str):
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index aa9b553cb6760a..6f92dec5361eb6 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
- "requirements": ["aiounifi==79"],
+ "requirements": ["aiounifi==80"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index d86b72d1b2f1f2..08bd0ddb869a27 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -44,6 +44,7 @@
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
+from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
from . import UnifiConfigEntry
@@ -247,8 +248,9 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
def make_wan_latency_entity_description(
wan: Literal["WAN", "WAN2"], name: str, monitor_target: str
) -> UnifiSensorEntityDescription:
+ name_wan = f"{name} {wan}"
return UnifiSensorEntityDescription[Devices, Device](
- key=f"{name} {wan} latency",
+ key=f"{name_wan} latency",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
@@ -257,13 +259,12 @@ def make_wan_latency_entity_description(
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,
device_info_fn=async_device_device_info_fn,
- name_fn=lambda _: f"{name} {wan} latency",
+ name_fn=lambda device: f"{name_wan} latency",
object_fn=lambda api, obj_id: api.devices[obj_id],
supported_fn=partial(
async_device_wan_latency_supported_fn, wan, monitor_target
),
- unique_id_fn=lambda hub,
- obj_id: f"{name.lower}_{wan.lower}_latency-{obj_id}",
+ unique_id_fn=lambda hub, obj_id: f"{slugify(name_wan)}_latency-{obj_id}",
value_fn=partial(async_device_wan_latency_value_fn, wan, monitor_target),
)
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index afc4b9a06e6fdc..4483a5990eb6ef 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
- "requirements": ["uiprotect==5.4.0", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==6.0.2", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py
index e469b68451823b..2c6314892171d3 100644
--- a/homeassistant/components/unifiprotect/migrate.py
+++ b/homeassistant/components/unifiprotect/migrate.py
@@ -107,20 +107,18 @@ async def async_migrate_data(
) -> None:
"""Run all valid UniFi Protect data migrations."""
- _LOGGER.debug("Start Migrate: async_deprecate_hdr_package")
- async_deprecate_hdr_package(hass, entry)
- _LOGGER.debug("Completed Migrate: async_deprecate_hdr_package")
+ _LOGGER.debug("Start Migrate: async_deprecate_hdr")
+ async_deprecate_hdr(hass, entry)
+ _LOGGER.debug("Completed Migrate: async_deprecate_hdr")
@callback
-def async_deprecate_hdr_package(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
- """Check for usages of hdr_mode switch and package sensor and raise repair if it is used.
+def async_deprecate_hdr(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
+ """Check for usages of hdr_mode switch and raise repair if it is used.
UniFi Protect v3.0.22 changed how HDR works so it is no longer a simple on/off toggle. There is
Always On, Always Off and Auto. So it has been migrated to a select. The old switch is now deprecated.
- Additionally, the Package sensor is no longer functional due to how events work so a repair to notify users.
-
Added in 2024.4.0
"""
@@ -128,11 +126,5 @@ def async_deprecate_hdr_package(hass: HomeAssistant, entry: UFPConfigEntry) -> N
hass,
entry,
"2024.10.0",
- {
- "hdr_switch": {"id": "hdr_mode", "platform": Platform.SWITCH},
- "package_sensor": {
- "id": "smart_obj_package",
- "platform": Platform.BINARY_SENSOR,
- },
- },
+ {"hdr_switch": {"id": "hdr_mode", "platform": Platform.SWITCH}},
)
diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json
index f785498c00509b..aaef111a3517bd 100644
--- a/homeassistant/components/unifiprotect/strings.json
+++ b/homeassistant/components/unifiprotect/strings.json
@@ -124,10 +124,6 @@
"deprecate_hdr_switch": {
"title": "HDR Mode Switch Deprecated",
"description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly."
- },
- "deprecate_package_sensor": {
- "title": "Package Event Sensor Deprecated",
- "description": "The package event sensor never tripped because of the way events are reported in UniFi Protect. As a result, the sensor is deprecated and will be removed.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly."
}
},
"entity": {
diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py
index eb20fc949dcd09..881eda3525ff55 100644
--- a/homeassistant/components/upb/light.py
+++ b/homeassistant/components/upb/light.py
@@ -42,7 +42,7 @@ async def async_setup_entry(
SERVICE_LIGHT_FADE_START, UPB_BRIGHTNESS_RATE_SCHEMA, "async_light_fade_start"
)
platform.async_register_entity_service(
- SERVICE_LIGHT_FADE_STOP, {}, "async_light_fade_stop"
+ SERVICE_LIGHT_FADE_STOP, None, "async_light_fade_stop"
)
platform.async_register_entity_service(
SERVICE_LIGHT_BLINK, UPB_BLINK_RATE_SCHEMA, "async_light_blink"
diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py
index 9cf6788de4f604..276b620d5b53a9 100644
--- a/homeassistant/components/upb/scene.py
+++ b/homeassistant/components/upb/scene.py
@@ -31,10 +31,10 @@ async def async_setup_entry(
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
- SERVICE_LINK_DEACTIVATE, {}, "async_link_deactivate"
+ SERVICE_LINK_DEACTIVATE, None, "async_link_deactivate"
)
platform.async_register_entity_service(
- SERVICE_LINK_FADE_STOP, {}, "async_link_fade_stop"
+ SERVICE_LINK_FADE_STOP, None, "async_link_fade_stop"
)
platform.async_register_entity_service(
SERVICE_LINK_GOTO, UPB_BRIGHTNESS_RATE_SCHEMA, "async_link_goto"
diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py
index e7813b354c18ef..cd52de6550ffff 100644
--- a/homeassistant/components/update/__init__.py
+++ b/homeassistant/components/update/__init__.py
@@ -95,12 +95,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_SKIP,
- {},
+ None,
async_skip,
)
component.async_register_entity_service(
"clear_skipped",
- {},
+ None,
async_clear_skipped,
)
diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py
index 9b51e548f8045d..214521ee9c0c67 100644
--- a/homeassistant/components/upnp/__init__.py
+++ b/homeassistant/components/upnp/__init__.py
@@ -12,7 +12,7 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import device_registry as dr
from .const import (
CONFIG_ENTRY_FORCE_POLL,
@@ -35,7 +35,6 @@
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator]
diff --git a/homeassistant/components/uvc/manifest.json b/homeassistant/components/uvc/manifest.json
index 57e798c3fa631d..c72b865b5efbfd 100644
--- a/homeassistant/components/uvc/manifest.json
+++ b/homeassistant/components/uvc/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/uvc",
"iot_class": "local_polling",
"loggers": ["uvcclient"],
- "requirements": ["uvcclient==0.11.0"]
+ "requirements": ["uvcclient==0.12.1"]
}
diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json
index 1b76b6699567be..6b0a41bf7521d7 100644
--- a/homeassistant/components/v2c/icons.json
+++ b/homeassistant/components/v2c/icons.json
@@ -21,6 +21,15 @@
},
"battery_power": {
"default": "mdi:home-battery"
+ },
+ "ssid": {
+ "default": "mdi:wifi"
+ },
+ "ip_address": {
+ "default": "mdi:ip"
+ },
+ "signal_status": {
+ "default": "mdi:signal"
}
},
"switch": {
diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json
index ffe4b52ee6e537..3a6eab0f335d20 100644
--- a/homeassistant/components/v2c/manifest.json
+++ b/homeassistant/components/v2c/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/v2c",
"iot_class": "local_polling",
- "requirements": ["pytrydan==0.7.0"]
+ "requirements": ["pytrydan==0.8.0"]
}
diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py
index 2ff702261323a4..1540b098cf1e9c 100644
--- a/homeassistant/components/v2c/number.py
+++ b/homeassistant/components/v2c/number.py
@@ -13,6 +13,7 @@
NumberEntity,
NumberEntityDescription,
)
+from homeassistant.const import EntityCategory, UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -37,11 +38,34 @@ class V2CSettingsNumberEntityDescription(NumberEntityDescription):
key="intensity",
translation_key="intensity",
device_class=NumberDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
native_min_value=MIN_INTENSITY,
native_max_value=MAX_INTENSITY,
value_fn=lambda evse_data: evse_data.intensity,
update_fn=lambda evse, value: evse.intensity(value),
),
+ V2CSettingsNumberEntityDescription(
+ key="min_intensity",
+ translation_key="min_intensity",
+ device_class=NumberDeviceClass.CURRENT,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ native_min_value=MIN_INTENSITY,
+ native_max_value=MAX_INTENSITY,
+ value_fn=lambda evse_data: evse_data.min_intensity,
+ update_fn=lambda evse, value: evse.min_intensity(value),
+ ),
+ V2CSettingsNumberEntityDescription(
+ key="max_intensity",
+ translation_key="max_intensity",
+ device_class=NumberDeviceClass.CURRENT,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ native_min_value=MIN_INTENSITY,
+ native_max_value=MAX_INTENSITY,
+ value_fn=lambda evse_data: evse_data.max_intensity,
+ update_fn=lambda evse, value: evse.max_intensity(value),
+ ),
)
diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py
index fc0cc0bfaa8202..97853740e9d489 100644
--- a/homeassistant/components/v2c/sensor.py
+++ b/homeassistant/components/v2c/sensor.py
@@ -15,7 +15,13 @@
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfPower,
+ UnitOfTime,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -45,12 +51,20 @@ def get_meter_value(value: SlaveCommunicationState) -> str:
V2CSensorEntityDescription(
key="charge_power",
translation_key="charge_power",
- icon="mdi:ev-station",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
value_fn=lambda evse_data: evse_data.charge_power,
),
+ V2CSensorEntityDescription(
+ key="voltage_installation",
+ translation_key="voltage_installation",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ value_fn=lambda evse_data: evse_data.voltage_installation,
+ entity_registry_enabled_default=False,
+ ),
V2CSensorEntityDescription(
key="charge_energy",
translation_key="charge_energy",
@@ -86,6 +100,7 @@ def get_meter_value(value: SlaveCommunicationState) -> str:
V2CSensorEntityDescription(
key="meter_error",
translation_key="meter_error",
+ entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda evse_data: get_meter_value(evse_data.slave_error),
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
@@ -100,6 +115,28 @@ def get_meter_value(value: SlaveCommunicationState) -> str:
value_fn=lambda evse_data: evse_data.battery_power,
entity_registry_enabled_default=False,
),
+ V2CSensorEntityDescription(
+ key="ssid",
+ translation_key="ssid",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda evse_data: evse_data.SSID,
+ entity_registry_enabled_default=False,
+ ),
+ V2CSensorEntityDescription(
+ key="ip_address",
+ translation_key="ip_address",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda evse_data: evse_data.IP,
+ entity_registry_enabled_default=False,
+ ),
+ V2CSensorEntityDescription(
+ key="signal_status",
+ translation_key="signal_status",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda evse_data: evse_data.signal_status,
+ entity_registry_enabled_default=False,
+ ),
)
diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json
index 3342652cfb4411..d52b8f066f9159 100644
--- a/homeassistant/components/v2c/strings.json
+++ b/homeassistant/components/v2c/strings.json
@@ -33,12 +33,21 @@
"number": {
"intensity": {
"name": "Intensity"
+ },
+ "max_intensity": {
+ "name": "Max intensity"
+ },
+ "min_intensity": {
+ "name": "Min intensity"
}
},
"sensor": {
"charge_power": {
"name": "Charge power"
},
+ "voltage_installation": {
+ "name": "Installation voltage"
+ },
"charge_energy": {
"name": "Charge energy"
},
@@ -93,6 +102,15 @@
"empty_message": "Empty message",
"undefined_error": "Undefined error"
}
+ },
+ "ssid": {
+ "name": "SSID"
+ },
+ "ip_address": {
+ "name": "IP address"
+ },
+ "signal_status": {
+ "name": "Signal status"
}
},
"switch": {
diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py
index 90018e2d8ccb0c..867e25d4b2a00c 100644
--- a/homeassistant/components/vacuum/__init__.py
+++ b/homeassistant/components/vacuum/__init__.py
@@ -116,37 +116,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_START,
- {},
+ None,
"async_start",
[VacuumEntityFeature.START],
)
component.async_register_entity_service(
SERVICE_PAUSE,
- {},
+ None,
"async_pause",
[VacuumEntityFeature.PAUSE],
)
component.async_register_entity_service(
SERVICE_RETURN_TO_BASE,
- {},
+ None,
"async_return_to_base",
[VacuumEntityFeature.RETURN_HOME],
)
component.async_register_entity_service(
SERVICE_CLEAN_SPOT,
- {},
+ None,
"async_clean_spot",
[VacuumEntityFeature.CLEAN_SPOT],
)
component.async_register_entity_service(
SERVICE_LOCATE,
- {},
+ None,
"async_locate",
[VacuumEntityFeature.LOCATE],
)
component.async_register_entity_service(
SERVICE_STOP,
- {},
+ None,
"async_stop",
[VacuumEntityFeature.STOP],
)
diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py
index e97a68c2e82278..04ce12e8a8f5cb 100644
--- a/homeassistant/components/valve/__init__.py
+++ b/homeassistant/components/valve/__init__.py
@@ -71,11 +71,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config)
component.async_register_entity_service(
- SERVICE_OPEN_VALVE, {}, "async_handle_open_valve", [ValveEntityFeature.OPEN]
+ SERVICE_OPEN_VALVE, None, "async_handle_open_valve", [ValveEntityFeature.OPEN]
)
component.async_register_entity_service(
- SERVICE_CLOSE_VALVE, {}, "async_handle_close_valve", [ValveEntityFeature.CLOSE]
+ SERVICE_CLOSE_VALVE,
+ None,
+ "async_handle_close_valve",
+ [ValveEntityFeature.CLOSE],
)
component.async_register_entity_service(
@@ -90,12 +93,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
component.async_register_entity_service(
- SERVICE_STOP_VALVE, {}, "async_stop_valve", [ValveEntityFeature.STOP]
+ SERVICE_STOP_VALVE, None, "async_stop_valve", [ValveEntityFeature.STOP]
)
component.async_register_entity_service(
SERVICE_TOGGLE,
- {},
+ None,
"async_toggle",
[ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE],
)
@@ -223,7 +226,8 @@ async def async_open_valve(self) -> None:
async def async_handle_open_valve(self) -> None:
"""Open the valve."""
if self.supported_features & ValveEntityFeature.SET_POSITION:
- return await self.async_set_valve_position(100)
+ await self.async_set_valve_position(100)
+ return
await self.async_open_valve()
def close_valve(self) -> None:
@@ -238,7 +242,8 @@ async def async_close_valve(self) -> None:
async def async_handle_close_valve(self) -> None:
"""Close the valve."""
if self.supported_features & ValveEntityFeature.SET_POSITION:
- return await self.async_set_valve_position(0)
+ await self.async_set_valve_position(0)
+ return
await self.async_close_valve()
async def async_toggle(self) -> None:
diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json
index 1261d1cc398668..2c887ebf273c2b 100644
--- a/homeassistant/components/valve/icons.json
+++ b/homeassistant/components/valve/icons.json
@@ -3,7 +3,7 @@
"_": {
"default": "mdi:valve-open",
"state": {
- "off": "mdi:valve-closed"
+ "closed": "mdi:valve-closed"
}
},
"gas": {
@@ -12,7 +12,7 @@
"water": {
"default": "mdi:valve-open",
"state": {
- "off": "mdi:valve-closed"
+ "closed": "mdi:valve-closed"
}
}
},
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index d47444e3994b25..685f8b49500b17 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -119,7 +119,6 @@ async def syn_clock(call: ServiceCall) -> None:
async def set_memo_text(call: ServiceCall) -> None:
"""Handle Memo Text service call."""
memo_text = call.data[CONF_MEMO_TEXT]
- memo_text.hass = hass
await (
hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"]
.get_module(call.data[CONF_ADDRESS])
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index 4e9478ae575c17..c1cf2951bbd985 100644
--- a/homeassistant/components/velbus/manifest.json
+++ b/homeassistant/components/velbus/manifest.json
@@ -13,7 +13,7 @@
"velbus-packet",
"velbus-protocol"
],
- "requirements": ["velbus-aio==2024.7.5"],
+ "requirements": ["velbus-aio==2024.7.6"],
"usb": [
{
"vid": "10CF",
diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py
index 4b89fc66a84634..614ed810429d5e 100644
--- a/homeassistant/components/velux/__init__.py
+++ b/homeassistant/components/velux/__init__.py
@@ -1,48 +1,14 @@
"""Support for VELUX KLF 200 devices."""
from pyvlx import Node, PyVLX, PyVLXException
-import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, ServiceCall, callback
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, PLATFORMS
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- }
- )
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the velux component."""
- if DOMAIN not in config:
- return True
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config[DOMAIN],
- )
- )
-
- return True
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the velux component."""
@@ -108,10 +74,14 @@ class VeluxEntity(Entity):
_attr_should_poll = False
- def __init__(self, node: Node) -> None:
+ def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device."""
self.node = node
- self._attr_unique_id = node.serial_number
+ self._attr_unique_id = (
+ node.serial_number
+ if node.serial_number
+ else f"{config_entry_id}_{node.node_id}"
+ )
self._attr_name = node.name if node.name else f"#{node.node_id}"
@callback
diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py
index c0d4ec8035ba57..f4bfa13b4d53ca 100644
--- a/homeassistant/components/velux/config_flow.py
+++ b/homeassistant/components/velux/config_flow.py
@@ -1,15 +1,11 @@
"""Config flow for Velux integration."""
-from typing import Any
-
from pyvlx import PyVLX, PyVLXException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN, LOGGER
@@ -24,59 +20,6 @@
class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for velux."""
- async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult:
- """Import a config entry."""
-
- def create_repair(error: str | None = None) -> None:
- if error:
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{error}",
- breaks_in_ha_version="2024.9.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{error}",
- )
- else:
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.9.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Velux",
- },
- )
-
- for entry in self._async_current_entries():
- if entry.data[CONF_HOST] == config[CONF_HOST]:
- create_repair()
- return self.async_abort(reason="already_configured")
-
- pyvlx = PyVLX(host=config[CONF_HOST], password=config[CONF_PASSWORD])
- try:
- await pyvlx.connect()
- await pyvlx.disconnect()
- except (PyVLXException, ConnectionError):
- create_repair("cannot_connect")
- return self.async_abort(reason="cannot_connect")
- except Exception: # noqa: BLE001
- create_repair("unknown")
- return self.async_abort(reason="unknown")
-
- create_repair()
- return self.async_create_entry(
- title=config[CONF_HOST],
- data=config,
- )
-
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py
index c8688e4d18687d..cd7564eee8198b 100644
--- a/homeassistant/components/velux/cover.py
+++ b/homeassistant/components/velux/cover.py
@@ -29,7 +29,7 @@ async def async_setup_entry(
"""Set up cover(s) for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
async_add_entities(
- VeluxCover(node)
+ VeluxCover(node, config.entry_id)
for node in module.pyvlx.nodes
if isinstance(node, OpeningDevice)
)
@@ -41,9 +41,9 @@ class VeluxCover(VeluxEntity, CoverEntity):
_is_blind = False
node: OpeningDevice
- def __init__(self, node: OpeningDevice) -> None:
+ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxCover."""
- super().__init__(node)
+ super().__init__(node, config_entry_id)
self._attr_device_class = CoverDeviceClass.WINDOW
if isinstance(node, Awning):
self._attr_device_class = CoverDeviceClass.AWNING
diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py
index bbe9822648e301..e98632701f329b 100644
--- a/homeassistant/components/velux/light.py
+++ b/homeassistant/components/velux/light.py
@@ -23,7 +23,7 @@ async def async_setup_entry(
module = hass.data[DOMAIN][config.entry_id]
async_add_entities(
- VeluxLight(node)
+ VeluxLight(node, config.entry_id)
for node in module.pyvlx.nodes
if isinstance(node, LighteningDevice)
)
diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json
index 3964c22efe261c..5b7b459a3f76c1 100644
--- a/homeassistant/components/velux/strings.json
+++ b/homeassistant/components/velux/strings.json
@@ -17,16 +17,6 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
- "issues": {
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The Velux YAML configuration import cannot connect to server",
- "description": "Configuring Velux using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the KLF 200."
- },
- "deprecated_yaml_import_issue_unknown": {
- "title": "The Velux YAML configuration import failed with unknown error raised by pyvlx",
- "description": "Configuring Velux using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration."
- }
- },
"services": {
"reboot_gateway": {
"name": "Reboot gateway",
diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py
index 542680925f22e8..25ffe987d5e61e 100644
--- a/homeassistant/components/vera/cover.py
+++ b/homeassistant/components/vera/cover.py
@@ -61,10 +61,11 @@ def set_cover_position(self, **kwargs: Any) -> None:
self.schedule_update_ha_state()
@property
- def is_closed(self) -> bool:
+ def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
if self.current_cover_position is not None:
return self.current_cover_position == 0
+ return None
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py
index 9e5f0ca2703719..0f8c8d936ef2be 100644
--- a/homeassistant/components/verisure/__init__.py
+++ b/homeassistant/components/verisure/__init__.py
@@ -12,7 +12,6 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import STORAGE_DIR
from .const import CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER
@@ -27,8 +26,6 @@
Platform.SWITCH,
]
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Verisure from a config entry."""
diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py
index 72f5ab93c700ee..50606a49eab1a2 100644
--- a/homeassistant/components/verisure/camera.py
+++ b/homeassistant/components/verisure/camera.py
@@ -33,7 +33,7 @@ async def async_setup_entry(
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_CAPTURE_SMARTCAM,
- {},
+ None,
VerisureSmartcam.capture_smartcam.__name__,
)
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
index da2bc2ced2b9ac..5c56fc0df2c7c1 100644
--- a/homeassistant/components/verisure/lock.py
+++ b/homeassistant/components/verisure/lock.py
@@ -41,12 +41,12 @@ async def async_setup_entry(
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_DISABLE_AUTOLOCK,
- {},
+ None,
VerisureDoorlock.disable_autolock.__name__,
)
platform.async_register_entity_service(
SERVICE_ENABLE_AUTOLOCK,
- {},
+ None,
VerisureDoorlock.enable_autolock.__name__,
)
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index 7dceb1b3f8faf7..04547d33deab7a 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -7,7 +7,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .common import async_process_devices
@@ -26,8 +25,6 @@
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
-
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Vesync as config entry."""
diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py
index 286dcbbdfa8e6e..8f8ae3c94e3f68 100644
--- a/homeassistant/components/vicare/const.py
+++ b/homeassistant/components/vicare/const.py
@@ -10,6 +10,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
+ Platform.FAN,
Platform.NUMBER,
Platform.SENSOR,
Platform.WATER_HEATER,
diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py
new file mode 100644
index 00000000000000..088e54c7354789
--- /dev/null
+++ b/homeassistant/components/vicare/fan.py
@@ -0,0 +1,124 @@
+"""Viessmann ViCare ventilation device."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+import logging
+
+from PyViCare.PyViCareDevice import Device as PyViCareDevice
+from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
+from PyViCare.PyViCareUtils import (
+ PyViCareInvalidDataError,
+ PyViCareNotSupportedFeatureError,
+ PyViCareRateLimitError,
+)
+from PyViCare.PyViCareVentilationDevice import (
+ VentilationDevice as PyViCareVentilationDevice,
+)
+from requests.exceptions import ConnectionError as RequestConnectionError
+
+from homeassistant.components.fan import FanEntity, FanEntityFeature
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.util.percentage import (
+ ordered_list_item_to_percentage,
+ percentage_to_ordered_list_item,
+)
+
+from .const import DEVICE_LIST, DOMAIN
+from .entity import ViCareEntity
+from .types import VentilationMode, VentilationProgram
+
+_LOGGER = logging.getLogger(__name__)
+
+ORDERED_NAMED_FAN_SPEEDS = [
+ VentilationProgram.LEVEL_ONE,
+ VentilationProgram.LEVEL_TWO,
+ VentilationProgram.LEVEL_THREE,
+ VentilationProgram.LEVEL_FOUR,
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the ViCare fan platform."""
+
+ device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
+
+ async_add_entities(
+ [
+ ViCareFan(device.config, device.api)
+ for device in device_list
+ if isinstance(device.api, PyViCareVentilationDevice)
+ ]
+ )
+
+
+class ViCareFan(ViCareEntity, FanEntity):
+ """Representation of the ViCare ventilation device."""
+
+ _attr_preset_modes = list[str](
+ [
+ VentilationMode.PERMANENT,
+ VentilationMode.VENTILATION,
+ VentilationMode.SENSOR_DRIVEN,
+ VentilationMode.SENSOR_OVERRIDE,
+ ]
+ )
+ _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
+ _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
+ _attr_translation_key = "ventilation"
+ _enable_turn_on_off_backwards_compatibility = False
+
+ def __init__(
+ self,
+ device_config: PyViCareDeviceConfig,
+ device: PyViCareDevice,
+ ) -> None:
+ """Initialize the fan entity."""
+ super().__init__(device_config, device, self._attr_translation_key)
+
+ def update(self) -> None:
+ """Update state of fan."""
+ try:
+ with suppress(PyViCareNotSupportedFeatureError):
+ self._attr_preset_mode = VentilationMode.from_vicare_mode(
+ self._api.getActiveMode()
+ )
+ with suppress(PyViCareNotSupportedFeatureError):
+ self._attr_percentage = ordered_list_item_to_percentage(
+ ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram()
+ )
+ except RequestConnectionError:
+ _LOGGER.error("Unable to retrieve data from ViCare server")
+ except ValueError:
+ _LOGGER.error("Unable to decode data from ViCare server")
+ except PyViCareRateLimitError as limit_exception:
+ _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
+ except PyViCareInvalidDataError as invalid_data_exception:
+ _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return true if the entity is on."""
+ # Viessmann ventilation unit cannot be turned off
+ return True
+
+ def set_percentage(self, percentage: int) -> None:
+ """Set the speed of the fan, as a percentage."""
+ if self._attr_preset_mode != str(VentilationMode.PERMANENT):
+ self.set_preset_mode(VentilationMode.PERMANENT)
+
+ level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
+ _LOGGER.debug("changing ventilation level to %s", level)
+ self._api.setPermanentLevel(level)
+
+ def set_preset_mode(self, preset_mode: str) -> None:
+ """Set new preset mode."""
+ target_mode = VentilationMode.to_vicare_mode(preset_mode)
+ _LOGGER.debug("changing ventilation mode to %s", target_mode)
+ self._api.setActiveMode(target_mode)
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index de92d0ec2710b8..0452a560cb879f 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -65,6 +65,21 @@
"name": "Heating"
}
},
+ "fan": {
+ "ventilation": {
+ "name": "Ventilation",
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "permanent": "permanent",
+ "ventilation": "schedule",
+ "sensor_driven": "sensor",
+ "sensor_override": "schedule with sensor-override"
+ }
+ }
+ }
+ }
+ },
"number": {
"heating_curve_shift": {
"name": "Heating curve shift"
@@ -304,8 +319,8 @@
"ess_discharge_total": {
"name": "Battery discharge total"
},
- "pcc_current_power_exchange": {
- "name": "Grid power exchange"
+ "pcc_transfer_power_exchange": {
+ "name": "Power exchange with grid"
},
"pcc_energy_consumption": {
"name": "Energy import from grid"
diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py
index 7e1ec7f8beee0a..596605fccdd0f3 100644
--- a/homeassistant/components/vicare/types.py
+++ b/homeassistant/components/vicare/types.py
@@ -64,6 +64,55 @@ def from_ha_preset(
}
+class VentilationMode(enum.StrEnum):
+ """ViCare ventilation modes."""
+
+ PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour)
+ VENTILATION = "ventilation" # activated by schedule
+ SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor
+ SENSOR_OVERRIDE = "sensor_override" # activated by sensor
+
+ @staticmethod
+ def to_vicare_mode(mode: str | None) -> str | None:
+ """Return the mapped ViCare ventilation mode for the Home Assistant mode."""
+ if mode:
+ try:
+ ventilation_mode = VentilationMode(mode)
+ except ValueError:
+ # ignore unsupported / unmapped modes
+ return None
+ return HA_TO_VICARE_MODE_VENTILATION.get(ventilation_mode) if mode else None
+ return None
+
+ @staticmethod
+ def from_vicare_mode(vicare_mode: str | None) -> str | None:
+ """Return the mapped Home Assistant mode for the ViCare ventilation mode."""
+ for mode in VentilationMode:
+ if HA_TO_VICARE_MODE_VENTILATION.get(VentilationMode(mode)) == vicare_mode:
+ return mode
+ return None
+
+
+HA_TO_VICARE_MODE_VENTILATION = {
+ VentilationMode.PERMANENT: "permanent",
+ VentilationMode.VENTILATION: "ventilation",
+ VentilationMode.SENSOR_DRIVEN: "sensorDriven",
+ VentilationMode.SENSOR_OVERRIDE: "sensorOverride",
+}
+
+
+class VentilationProgram(enum.StrEnum):
+ """ViCare preset ventilation programs.
+
+ As listed in https://github.com/somm15/PyViCare/blob/6c5b023ca6c8bb2d38141dd1746dc1705ec84ce8/PyViCare/PyViCareVentilationDevice.py#L37
+ """
+
+ LEVEL_ONE = "levelOne"
+ LEVEL_TWO = "levelTwo"
+ LEVEL_THREE = "levelThree"
+ LEVEL_FOUR = "levelFour"
+
+
@dataclass(frozen=True)
class ViCareDevice:
"""Dataclass holding the device api and config."""
diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py
index 5770d9d2b4a2df..161e938a3b674d 100644
--- a/homeassistant/components/voip/voip.py
+++ b/homeassistant/components/voip/voip.py
@@ -21,7 +21,7 @@
VoipDatagramProtocol,
)
-from homeassistant.components import stt, tts
+from homeassistant.components import assist_pipeline, stt, tts
from homeassistant.components.assist_pipeline import (
Pipeline,
PipelineEvent,
@@ -31,12 +31,14 @@
async_pipeline_from_audio_stream,
select as pipeline_select,
)
+from homeassistant.components.assist_pipeline.audio_enhancer import (
+ AudioEnhancer,
+ MicroVadEnhancer,
+)
from homeassistant.components.assist_pipeline.vad import (
AudioBuffer,
VadSensitivity,
- VoiceActivityDetector,
VoiceCommandSegmenter,
- WebRtcVad,
)
from homeassistant.const import __version__
from homeassistant.core import Context, HomeAssistant
@@ -233,13 +235,13 @@ async def _run_pipeline(
try:
# Wait for speech before starting pipeline
segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds)
- vad = WebRtcVad()
+ audio_enhancer = MicroVadEnhancer(0, 0, True)
chunk_buffer: deque[bytes] = deque(
maxlen=self.buffered_chunks_before_speech,
)
speech_detected = await self._wait_for_speech(
segmenter,
- vad,
+ audio_enhancer,
chunk_buffer,
)
if not speech_detected:
@@ -253,7 +255,7 @@ async def stt_stream():
try:
async for chunk in self._segment_audio(
segmenter,
- vad,
+ audio_enhancer,
chunk_buffer,
):
yield chunk
@@ -317,7 +319,7 @@ async def stt_stream():
async def _wait_for_speech(
self,
segmenter: VoiceCommandSegmenter,
- vad: VoiceActivityDetector,
+ audio_enhancer: AudioEnhancer,
chunk_buffer: MutableSequence[bytes],
):
"""Buffer audio chunks until speech is detected.
@@ -329,13 +331,17 @@ async def _wait_for_speech(
async with asyncio.timeout(self.audio_timeout):
chunk = await self._audio_queue.get()
- assert vad.samples_per_chunk is not None
- vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH)
+ vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH)
while chunk:
chunk_buffer.append(chunk)
- segmenter.process_with_vad(chunk, vad, vad_buffer)
+ segmenter.process_with_vad(
+ chunk,
+ assist_pipeline.SAMPLES_PER_CHUNK,
+ lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True,
+ vad_buffer,
+ )
if segmenter.in_command:
# Buffer until command starts
if len(vad_buffer) > 0:
@@ -351,7 +357,7 @@ async def _wait_for_speech(
async def _segment_audio(
self,
segmenter: VoiceCommandSegmenter,
- vad: VoiceActivityDetector,
+ audio_enhancer: AudioEnhancer,
chunk_buffer: Sequence[bytes],
) -> AsyncIterable[bytes]:
"""Yield audio chunks until voice command has finished."""
@@ -364,11 +370,15 @@ async def _segment_audio(
async with asyncio.timeout(self.audio_timeout):
chunk = await self._audio_queue.get()
- assert vad.samples_per_chunk is not None
- vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH)
+ vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH)
while chunk:
- if not segmenter.process_with_vad(chunk, vad, vad_buffer):
+ if not segmenter.process_with_vad(
+ chunk,
+ assist_pipeline.SAMPLES_PER_CHUNK,
+ lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True,
+ vad_buffer,
+ ):
# Voice command is finished
break
@@ -425,13 +435,13 @@ async def _send_tts(self, media_id: str) -> None:
sample_channels = wav_file.getnchannels()
if (
- (sample_rate != 16000)
- or (sample_width != 2)
- or (sample_channels != 1)
+ (sample_rate != RATE)
+ or (sample_width != WIDTH)
+ or (sample_channels != CHANNELS)
):
raise ValueError(
- "Expected rate/width/channels as 16000/2/1,"
- " got {sample_rate}/{sample_width}/{sample_channels}}"
+ f"Expected rate/width/channels as {RATE}/{WIDTH}/{CHANNELS},"
+ f" got {sample_rate}/{sample_width}/{sample_channels}"
)
audio_bytes = wav_file.readframes(wav_file.getnframes())
diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py
index 39c4511868df44..87135a61380db9 100644
--- a/homeassistant/components/wake_on_lan/button.py
+++ b/homeassistant/components/wake_on_lan/button.py
@@ -15,8 +15,6 @@
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
-
_LOGGER = logging.getLogger(__name__)
@@ -62,9 +60,8 @@ def __init__(
self._attr_unique_id = dr.format_mac(mac_address)
self._attr_device_info = dr.DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._attr_unique_id)},
- identifiers={(DOMAIN, self._attr_unique_id)},
- manufacturer="Wake on LAN",
- name=name,
+ default_manufacturer="Wake on LAN",
+ default_name=name,
)
async def async_press(self) -> None:
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
index 731a513fb66576..2e749735b0cc8c 100644
--- a/homeassistant/components/water_heater/__init__.py
+++ b/homeassistant/components/water_heater/__init__.py
@@ -35,7 +35,7 @@
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.temperature import display_temp as show_temp
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import DOMAIN
@@ -94,29 +94,17 @@ class WaterHeaterEntityFeature(IntFlag):
_LOGGER = logging.getLogger(__name__)
-ON_OFF_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids})
-
-SET_AWAY_MODE_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Required(ATTR_AWAY_MODE): cv.boolean,
- }
-)
-SET_TEMPERATURE_SCHEMA = vol.Schema(
- vol.All(
- {
- vol.Required(ATTR_TEMPERATURE, "temperature"): vol.Coerce(float),
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Optional(ATTR_OPERATION_MODE): cv.string,
- }
- )
-)
-SET_OPERATION_MODE_SCHEMA = vol.Schema(
- {
- vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
- vol.Required(ATTR_OPERATION_MODE): cv.string,
- }
-)
+SET_AWAY_MODE_SCHEMA: VolDictType = {
+ vol.Required(ATTR_AWAY_MODE): cv.boolean,
+}
+SET_TEMPERATURE_SCHEMA: VolDictType = {
+ vol.Required(ATTR_TEMPERATURE, "temperature"): vol.Coerce(float),
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_OPERATION_MODE): cv.string,
+}
+SET_OPERATION_MODE_SCHEMA: VolDictType = {
+ vol.Required(ATTR_OPERATION_MODE): cv.string,
+}
# mypy: disallow-any-generics
@@ -129,10 +117,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config)
component.async_register_entity_service(
- SERVICE_TURN_ON, {}, "async_turn_on", [WaterHeaterEntityFeature.ON_OFF]
+ SERVICE_TURN_ON, None, "async_turn_on", [WaterHeaterEntityFeature.ON_OFF]
)
component.async_register_entity_service(
- SERVICE_TURN_OFF, {}, "async_turn_off", [WaterHeaterEntityFeature.ON_OFF]
+ SERVICE_TURN_OFF, None, "async_turn_off", [WaterHeaterEntityFeature.ON_OFF]
)
component.async_register_entity_service(
SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, async_service_away_mode
@@ -145,12 +133,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SET_OPERATION_MODE_SCHEMA,
"async_handle_set_operation_mode",
)
- component.async_register_entity_service(
- SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, "async_turn_off"
- )
- component.async_register_entity_service(
- SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, "async_turn_on"
- )
return True
diff --git a/homeassistant/components/webhook/manifest.json b/homeassistant/components/webhook/manifest.json
index c2795e8ac17e4e..43f5321d9f636f 100644
--- a/homeassistant/components/webhook/manifest.json
+++ b/homeassistant/components/webhook/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/webhook",
+ "integration_type": "system",
"quality_scale": "internal"
}
diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py
index ef70df4a1232c2..6c0c6f0c5871dd 100644
--- a/homeassistant/components/websocket_api/connection.py
+++ b/homeassistant/components/websocket_api/connection.py
@@ -223,7 +223,7 @@ def async_handle(self, msg: JsonValueType) -> None:
try:
if schema is False:
if len(msg) > 2:
- raise vol.Invalid("extra keys not allowed")
+ raise vol.Invalid("extra keys not allowed") # noqa: TRY301
handler(self.hass, self, msg)
else:
handler(self.hass, self, schema(msg))
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index c65c4c65988141..8ed3469d7edc45 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -339,11 +339,11 @@ async def async_handle(self) -> web.WebSocketResponse:
raise Disconnect from err
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
- raise Disconnect
+ raise Disconnect # noqa: TRY301
if msg.type != WSMsgType.TEXT:
disconnect_warn = "Received non-Text message."
- raise Disconnect
+ raise Disconnect # noqa: TRY301
try:
auth_msg_data = json_loads(msg.data)
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
index b7c9840bcdc987..f9d3270aaa0f28 100644
--- a/homeassistant/components/wemo/fan.py
+++ b/homeassistant/components/wemo/fan.py
@@ -67,7 +67,7 @@ async def _discovered_wemo(coordinator: DeviceCoordinator) -> None:
# This will call WemoHumidifier.reset_filter_life()
platform.async_register_entity_service(
- SERVICE_RESET_FILTER_LIFE, {}, WemoHumidifier.reset_filter_life.__name__
+ SERVICE_RESET_FILTER_LIFE, None, WemoHumidifier.reset_filter_life.__name__
)
diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json
index b19e5f16ccbbb6..71939127356f38 100644
--- a/homeassistant/components/wled/manifest.json
+++ b/homeassistant/components/wled/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
- "requirements": ["wled==0.20.0"],
+ "requirements": ["wled==0.20.2"],
"zeroconf": ["_wled._tcp.local."]
}
diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py
index 3aae6746ea992e..73714b75c95f4a 100644
--- a/homeassistant/components/wsdot/sensor.py
+++ b/homeassistant/components/wsdot/sensor.py
@@ -6,6 +6,7 @@
from http import HTTPStatus
import logging
import re
+from typing import Any
import requests
import voluptuous as vol
@@ -125,7 +126,7 @@ def update(self) -> None:
self._state = self._data.get(ATTR_CURRENT_TIME)
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return other details about the sensor state."""
if self._data is not None:
attrs = {}
@@ -140,6 +141,7 @@ def extra_state_attributes(self):
self._data.get(ATTR_TIME_UPDATED)
)
return attrs
+ return None
def _parse_wsdot_timestamp(timestamp):
diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py
index e333a740741ec3..1ee0f24f8052b6 100644
--- a/homeassistant/components/wyoming/data.py
+++ b/homeassistant/components/wyoming/data.py
@@ -100,7 +100,7 @@ async def load_wyoming_info(
while True:
event = await client.read_event()
if event is None:
- raise WyomingError(
+ raise WyomingError( # noqa: TRY301
"Connection closed unexpectedly",
)
diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py
index 2ca66f3b21a1ef..2e00b31fd34e57 100644
--- a/homeassistant/components/wyoming/devices.py
+++ b/homeassistant/components/wyoming/devices.py
@@ -5,6 +5,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from homeassistant.components.assist_pipeline.vad import VadSensitivity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
@@ -23,6 +24,7 @@ class SatelliteDevice:
noise_suppression_level: int = 0
auto_gain: int = 0
volume_multiplier: float = 1.0
+ vad_sensitivity: VadSensitivity = VadSensitivity.DEFAULT
_is_active_listener: Callable[[], None] | None = None
_is_muted_listener: Callable[[], None] | None = None
@@ -77,6 +79,14 @@ def set_volume_multiplier(self, volume_multiplier: float) -> None:
if self._audio_settings_listener is not None:
self._audio_settings_listener()
+ @callback
+ def set_vad_sensitivity(self, vad_sensitivity: VadSensitivity) -> None:
+ """Set VAD sensitivity."""
+ if vad_sensitivity != self.vad_sensitivity:
+ self.vad_sensitivity = vad_sensitivity
+ if self._audio_settings_listener is not None:
+ self._audio_settings_listener()
+
@callback
def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None:
"""Listen for updates to is_active."""
@@ -140,3 +150,10 @@ def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None:
return ent_reg.async_get_entity_id(
"number", DOMAIN, f"{self.satellite_id}-volume_multiplier"
)
+
+ def get_vad_sensitivity_entity_id(self, hass: HomeAssistant) -> str | None:
+ """Return entity id for VAD sensitivity."""
+ ent_reg = er.async_get(hass)
+ return ent_reg.async_get_entity_id(
+ "select", DOMAIN, f"{self.satellite_id}-vad_sensitivity"
+ )
diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py
index 3ca86a42e5d804..781f0706c680a6 100644
--- a/homeassistant/components/wyoming/satellite.py
+++ b/homeassistant/components/wyoming/satellite.py
@@ -25,6 +25,7 @@
from homeassistant.components import assist_pipeline, intent, stt, tts
from homeassistant.components.assist_pipeline import select as pipeline_select
+from homeassistant.components.assist_pipeline.vad import VadSensitivity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
@@ -409,6 +410,9 @@ def _run_pipeline_once(
noise_suppression_level=self.device.noise_suppression_level,
auto_gain_dbfs=self.device.auto_gain,
volume_multiplier=self.device.volume_multiplier,
+ silence_seconds=VadSensitivity.to_seconds(
+ self.device.vad_sensitivity
+ ),
),
device_id=self.device.device_id,
wake_word_phrase=wake_word_phrase,
diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py
index 99f26c3e440f04..f852b4d0434e3f 100644
--- a/homeassistant/components/wyoming/select.py
+++ b/homeassistant/components/wyoming/select.py
@@ -4,7 +4,11 @@
from typing import TYPE_CHECKING, Final
-from homeassistant.components.assist_pipeline.select import AssistPipelineSelect
+from homeassistant.components.assist_pipeline.select import (
+ AssistPipelineSelect,
+ VadSensitivitySelect,
+)
+from homeassistant.components.assist_pipeline.vad import VadSensitivity
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
@@ -45,6 +49,7 @@ async def async_setup_entry(
[
WyomingSatellitePipelineSelect(hass, device),
WyomingSatelliteNoiseSuppressionLevelSelect(device),
+ WyomingSatelliteVadSensitivitySelect(hass, device),
]
)
@@ -92,3 +97,21 @@ async def async_select_option(self, option: str) -> None:
self._attr_current_option = option
self.async_write_ha_state()
self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option])
+
+
+class WyomingSatelliteVadSensitivitySelect(
+ WyomingSatelliteEntity, VadSensitivitySelect
+):
+ """VAD sensitivity selector for Wyoming satellites."""
+
+ def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None:
+ """Initialize a VAD sensitivity selector."""
+ self.device = device
+
+ WyomingSatelliteEntity.__init__(self, device)
+ VadSensitivitySelect.__init__(self, hass, device.satellite_id)
+
+ async def async_select_option(self, option: str) -> None:
+ """Select an option."""
+ await super().async_select_option(option)
+ self.device.set_vad_sensitivity(VadSensitivity(option))
diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json
index f2768e45eb8aff..4a1a4c3a246077 100644
--- a/homeassistant/components/wyoming/strings.json
+++ b/homeassistant/components/wyoming/strings.json
@@ -46,6 +46,14 @@
"high": "High",
"max": "Max"
}
+ },
+ "vad_sensitivity": {
+ "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
+ "state": {
+ "default": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::default%]",
+ "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]",
+ "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]"
+ }
}
},
"switch": {
diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py
index 6eba0f7ca6d88b..64dfd60c068818 100644
--- a/homeassistant/components/wyoming/wake_word.py
+++ b/homeassistant/components/wyoming/wake_word.py
@@ -89,6 +89,7 @@ async def next_chunk():
"""Get the next chunk from audio stream."""
async for chunk_bytes in stream:
return chunk_bytes
+ return None
try:
async with AsyncTcpClient(self.service.host, self.service.port) as client:
diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py
index 323a0f8a157302..8ab15f85147c84 100644
--- a/homeassistant/components/xiaomi/camera.py
+++ b/homeassistant/components/xiaomi/camera.py
@@ -80,7 +80,6 @@ def __init__(self, hass, config):
self._manager = get_ffmpeg_manager(hass)
self._name = config[CONF_NAME]
self.host = config[CONF_HOST]
- self.host.hass = hass
self._model = config[CONF_MODEL]
self.port = config[CONF_PORT]
self.path = config[CONF_PATH]
diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py
index b3983e76aaa7a7..b14ec0739383e3 100644
--- a/homeassistant/components/xiaomi/device_tracker.py
+++ b/homeassistant/components/xiaomi/device_tracker.py
@@ -172,7 +172,6 @@ def _get_token(host, username, password):
)
_LOGGER.exception(error_message, url, data, result)
return None
- else:
- _LOGGER.error(
- "Invalid response: [%s] at url: [%s] with data [%s]", res, url, data
- )
+
+ _LOGGER.error("Invalid response: [%s] at url: [%s] with data [%s]", res, url, data)
+ return None
diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py
index cee2980fe07632..75208b142dd6f3 100644
--- a/homeassistant/components/xiaomi_aqara/binary_sensor.py
+++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py
@@ -202,6 +202,8 @@ def parse_data(self, data, raw_data):
return True
return False
+ return False
+
class XiaomiMotionSensor(XiaomiBinarySensor):
"""Representation of a XiaomiMotionSensor."""
@@ -298,6 +300,8 @@ def parse_data(self, data, raw_data):
self._state = True
return True
+ return False
+
class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity):
"""Representation of a XiaomiDoorSensor."""
@@ -357,6 +361,8 @@ def parse_data(self, data, raw_data):
return True
return False
+ return False
+
class XiaomiWaterLeakSensor(XiaomiBinarySensor):
"""Representation of a XiaomiWaterLeakSensor."""
@@ -401,6 +407,8 @@ def parse_data(self, data, raw_data):
return True
return False
+ return False
+
class XiaomiSmokeSensor(XiaomiBinarySensor):
"""Representation of a XiaomiSmokeSensor."""
@@ -443,6 +451,8 @@ def parse_data(self, data, raw_data):
return True
return False
+ return False
+
class XiaomiVibration(XiaomiBinarySensor):
"""Representation of a Xiaomi Vibration Sensor."""
diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py
index 7729ce27d29dfd..6d1a81007dc7dc 100644
--- a/homeassistant/components/xiaomi_miio/binary_sensor.py
+++ b/homeassistant/components/xiaomi_miio/binary_sensor.py
@@ -190,7 +190,8 @@ async def async_setup_entry(
elif model in MODELS_HUMIDIFIER_MJJSQ:
sensors = HUMIDIFIER_MJJSQ_BINARY_SENSORS
elif model in MODELS_VACUUM:
- return _setup_vacuum_sensors(hass, config_entry, async_add_entities)
+ _setup_vacuum_sensors(hass, config_entry, async_add_entities)
+ return
for description in BINARY_SENSOR_TYPES:
if description.key not in sensors:
diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py
index 959bf0a7beef15..72707109ad62c8 100644
--- a/homeassistant/components/xiaomi_miio/remote.py
+++ b/homeassistant/components/xiaomi_miio/remote.py
@@ -170,12 +170,12 @@ async def async_service_learn_handler(entity, service):
)
platform.async_register_entity_service(
SERVICE_SET_REMOTE_LED_ON,
- {},
+ None,
async_service_led_on_handler,
)
platform.async_register_entity_service(
SERVICE_SET_REMOTE_LED_OFF,
- {},
+ None,
async_service_led_off_handler,
)
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
index ef6f94c162fa06..ac833f7646ce56 100644
--- a/homeassistant/components/xiaomi_miio/vacuum.py
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -104,13 +104,13 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_START_REMOTE_CONTROL,
- {},
+ None,
MiroboVacuum.async_remote_control_start.__name__,
)
platform.async_register_entity_service(
SERVICE_STOP_REMOTE_CONTROL,
- {},
+ None,
MiroboVacuum.async_remote_control_stop.__name__,
)
diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py
index 1ef68d98a13da4..3c853afb6fda68 100644
--- a/homeassistant/components/yale_smart_alarm/__init__.py
+++ b/homeassistant/components/yale_smart_alarm/__init__.py
@@ -6,7 +6,6 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from .const import LOGGER, PLATFORMS
@@ -19,9 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool
"""Set up Yale from a config entry."""
coordinator = YaleDataUpdateCoordinator(hass, entry)
- if not await hass.async_add_executor_job(coordinator.get_updates):
- raise ConfigEntryAuthFailed
-
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py
index 5307e166e17410..328558d0aba73b 100644
--- a/homeassistant/components/yale_smart_alarm/coordinator.py
+++ b/homeassistant/components/yale_smart_alarm/coordinator.py
@@ -20,10 +20,11 @@
class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""A Yale Data Update Coordinator."""
+ yale: YaleSmartAlarmClient
+
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the Yale hub."""
self.entry = entry
- self.yale: YaleSmartAlarmClient | None = None
super().__init__(
hass,
LOGGER,
@@ -32,6 +33,17 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
always_update=False,
)
+ async def _async_setup(self) -> None:
+ """Set up connection to Yale."""
+ try:
+ self.yale = YaleSmartAlarmClient(
+ self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD]
+ )
+ except AuthenticationError as error:
+ raise ConfigEntryAuthFailed from error
+ except YALE_BASE_ERRORS as error:
+ raise UpdateFailed from error
+
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Yale."""
@@ -132,17 +144,6 @@ async def _async_update_data(self) -> dict[str, Any]:
def get_updates(self) -> dict[str, Any]:
"""Fetch data from Yale."""
-
- if self.yale is None:
- try:
- self.yale = YaleSmartAlarmClient(
- self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD]
- )
- except AuthenticationError as error:
- raise ConfigEntryAuthFailed from error
- except YALE_BASE_ERRORS as error:
- raise UpdateFailed from error
-
try:
arm_status = self.yale.get_armed_status()
data = self.yale.get_all()
diff --git a/homeassistant/components/yamaha/const.py b/homeassistant/components/yamaha/const.py
index 492babe9657367..1cdb619b6ef4e4 100644
--- a/homeassistant/components/yamaha/const.py
+++ b/homeassistant/components/yamaha/const.py
@@ -1,6 +1,7 @@
"""Constants for the Yamaha component."""
DOMAIN = "yamaha"
+DISCOVER_TIMEOUT = 3
KNOWN_ZONES = "known_zones"
CURSOR_TYPE_DOWN = "down"
CURSOR_TYPE_LEFT = "left"
diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py
index bf217f12ca4939..58f501b99be504 100644
--- a/homeassistant/components/yamaha/media_player.py
+++ b/homeassistant/components/yamaha/media_player.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import contextlib
import logging
from typing import Any
@@ -30,6 +31,7 @@
CURSOR_TYPE_RIGHT,
CURSOR_TYPE_SELECT,
CURSOR_TYPE_UP,
+ DISCOVER_TIMEOUT,
DOMAIN,
KNOWN_ZONES,
SERVICE_ENABLE_OUTPUT,
@@ -124,16 +126,35 @@ def _discovery(config_info):
elif config_info.host is None:
_LOGGER.debug("Config No Host Supplied Zones")
zones = []
- for recv in rxv.find():
+ for recv in rxv.find(DISCOVER_TIMEOUT):
zones.extend(recv.zone_controllers())
else:
_LOGGER.debug("Config Zones")
zones = None
- for recv in rxv.find():
- if recv.ctrl_url == config_info.ctrl_url:
- _LOGGER.debug("Config Zones Matched %s", config_info.ctrl_url)
- zones = recv.zone_controllers()
- break
+
+ # Fix for upstream issues in rxv.find() with some hardware.
+ with contextlib.suppress(AttributeError, ValueError):
+ for recv in rxv.find(DISCOVER_TIMEOUT):
+ _LOGGER.debug(
+ "Found Serial %s %s %s",
+ recv.serial_number,
+ recv.ctrl_url,
+ recv.zone,
+ )
+ if recv.ctrl_url == config_info.ctrl_url:
+ _LOGGER.debug(
+ "Config Zones Matched Serial %s: %s",
+ recv.ctrl_url,
+ recv.serial_number,
+ )
+ zones = rxv.RXV(
+ config_info.ctrl_url,
+ friendly_name=config_info.name,
+ serial_number=recv.serial_number,
+ model_name=recv.model_name,
+ ).zone_controllers()
+ break
+
if not zones:
_LOGGER.debug("Config Zones Fallback")
zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
@@ -165,7 +186,7 @@ async def async_setup_platform(
entities = []
for zctrl in zone_ctrls:
- _LOGGER.debug("Receiver zone: %s", zctrl.zone)
+ _LOGGER.debug("Receiver zone: %s serial %s", zctrl.zone, zctrl.serial_number)
if config_info.zone_ignore and zctrl.zone in config_info.zone_ignore:
_LOGGER.debug("Ignore receiver zone: %s %s", config_info.name, zctrl.zone)
continue
@@ -233,19 +254,6 @@ def __init__(self, name, zctrl, source_ignore, source_names, zone_names):
# the default name of the integration may not be changed
# to avoid a breaking change.
self._attr_unique_id = f"{self.zctrl.serial_number}_{self._zone}"
- _LOGGER.debug(
- "Receiver zone: %s zone %s uid %s",
- self._name,
- self._zone,
- self._attr_unique_id,
- )
- else:
- _LOGGER.info(
- "Receiver zone: %s zone %s no uid %s",
- self._name,
- self._zone,
- self._attr_unique_id,
- )
def update(self) -> None:
"""Get the latest details from the device."""
@@ -427,19 +435,21 @@ def select_sound_mode(self, sound_mode: str) -> None:
self.zctrl.surround_program = sound_mode
@property
- def media_artist(self):
+ def media_artist(self) -> str | None:
"""Artist of current playing media."""
if self._play_status is not None:
return self._play_status.artist
+ return None
@property
- def media_album_name(self):
+ def media_album_name(self) -> str | None:
"""Album of current playing media."""
if self._play_status is not None:
return self._play_status.album
+ return None
@property
- def media_content_type(self):
+ def media_content_type(self) -> MediaType | None:
"""Content type of current playing media."""
# Loose assumption that if playback is supported, we are playing music
if self._is_playback_supported:
@@ -447,7 +457,7 @@ def media_content_type(self):
return None
@property
- def media_title(self):
+ def media_title(self) -> str | None:
"""Artist of current playing media."""
if self._play_status is not None:
song = self._play_status.song
@@ -459,3 +469,4 @@ def media_title(self):
return f"{station}: {song}"
return song or station
+ return None
diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json
index c29b4d3dc983ce..1d1219d5a95600 100644
--- a/homeassistant/components/yandex_transport/manifest.json
+++ b/homeassistant/components/yandex_transport/manifest.json
@@ -4,5 +4,5 @@
"codeowners": ["@rishatik92", "@devbis"],
"documentation": "https://www.home-assistant.io/integrations/yandex_transport",
"iot_class": "cloud_polling",
- "requirements": ["aioymaps==1.2.4"]
+ "requirements": ["aioymaps==1.2.5"]
}
diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py
index 30227e3261e13b..95c4785a341392 100644
--- a/homeassistant/components/yandex_transport/sensor.py
+++ b/homeassistant/components/yandex_transport/sensor.py
@@ -5,7 +5,7 @@
from datetime import timedelta
import logging
-from aioymaps import CaptchaError, YandexMapsRequester
+from aioymaps import CaptchaError, NoSessionError, YandexMapsRequester
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -88,7 +88,7 @@ async def async_update(self, *, tries=0):
closer_time = None
try:
yandex_reply = await self.requester.get_stop_info(self._stop_id)
- except CaptchaError as ex:
+ except (CaptchaError, NoSessionError) as ex:
_LOGGER.error(
"%s. You may need to disable the integration for some time",
ex,
diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py
index 6ca12e9bd01c86..ac482504880f3c 100644
--- a/homeassistant/components/yeelight/scanner.py
+++ b/homeassistant/components/yeelight/scanner.py
@@ -67,7 +67,8 @@ def __init__(self, hass: HomeAssistant) -> None:
async def async_setup(self) -> None:
"""Set up the scanner."""
if self._setup_future is not None:
- return await self._setup_future
+ await self._setup_future
+ return
self._setup_future = self._hass.loop.create_future()
connected_futures: list[asyncio.Future[None]] = []
diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py
index 894c85d3f1b776..686160d92489e5 100644
--- a/homeassistant/components/yolink/const.py
+++ b/homeassistant/components/yolink/const.py
@@ -17,5 +17,9 @@
DEV_MODEL_WATER_METER_YS5007 = "YS5007"
DEV_MODEL_MULTI_OUTLET_YS6801 = "YS6801"
+DEV_MODEL_TH_SENSOR_YS8004_UC = "YS8004-UC"
+DEV_MODEL_TH_SENSOR_YS8004_EC = "YS8004-EC"
+DEV_MODEL_TH_SENSOR_YS8014_UC = "YS8014-UC"
+DEV_MODEL_TH_SENSOR_YS8014_EC = "YS8014-EC"
DEV_MODEL_TH_SENSOR_YS8017_UC = "YS8017-UC"
DEV_MODEL_TH_SENSOR_YS8017_EC = "YS8017-EC"
diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json
index 5353d5d5b8c9d5..78b553d7978c29 100644
--- a/homeassistant/components/yolink/manifest.json
+++ b/homeassistant/components/yolink/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
- "requirements": ["yolink-api==0.4.4"]
+ "requirements": ["yolink-api==0.4.7"]
}
diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py
index 4426602f133a86..77bbccb2f6a736 100644
--- a/homeassistant/components/yolink/sensor.py
+++ b/homeassistant/components/yolink/sensor.py
@@ -48,7 +48,15 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import percentage
-from .const import DEV_MODEL_TH_SENSOR_YS8017_EC, DEV_MODEL_TH_SENSOR_YS8017_UC, DOMAIN
+from .const import (
+ DEV_MODEL_TH_SENSOR_YS8004_EC,
+ DEV_MODEL_TH_SENSOR_YS8004_UC,
+ DEV_MODEL_TH_SENSOR_YS8014_EC,
+ DEV_MODEL_TH_SENSOR_YS8014_UC,
+ DEV_MODEL_TH_SENSOR_YS8017_EC,
+ DEV_MODEL_TH_SENSOR_YS8017_UC,
+ DOMAIN,
+)
from .coordinator import YoLinkCoordinator
from .entity import YoLinkEntity
@@ -109,6 +117,10 @@ class YoLinkSensorEntityDescription(SensorEntityDescription):
]
NONE_HUMIDITY_SENSOR_MODELS = [
+ DEV_MODEL_TH_SENSOR_YS8004_EC,
+ DEV_MODEL_TH_SENSOR_YS8004_UC,
+ DEV_MODEL_TH_SENSOR_YS8014_EC,
+ DEV_MODEL_TH_SENSOR_YS8014_UC,
DEV_MODEL_TH_SENSOR_YS8017_UC,
DEV_MODEL_TH_SENSOR_YS8017_EC,
]
diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py
index a24ad7d385d021..d8c199697c395b 100644
--- a/homeassistant/components/yolink/valve.py
+++ b/homeassistant/components/yolink/valve.py
@@ -37,7 +37,7 @@ class YoLinkValveEntityDescription(ValveEntityDescription):
key="valve_state",
translation_key="meter_valve_state",
device_class=ValveDeviceClass.WATER,
- value=lambda value: value == "closed" if value is not None else None,
+ value=lambda value: value != "open" if value is not None else None,
exists_fn=lambda device: device.device_type
== ATTR_DEVICE_WATER_METER_CONTROLLER
and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007),
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index 216261e30114e0..1897b741d8737b 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -2,6 +2,7 @@
import contextlib
import logging
+from zoneinfo import ZoneInfo
import voluptuous as vol
from zha.application.const import BAUD_RATES, RadioType
@@ -12,8 +13,13 @@
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP, Platform
-from homeassistant.core import Event, HomeAssistant
+from homeassistant.const import (
+ CONF_TYPE,
+ EVENT_CORE_CONFIG_UPDATE,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.config_validation as cv
@@ -117,6 +123,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
ha_zha_data.config_entry = config_entry
zha_lib_data: ZHAData = create_zha_config(hass, ha_zha_data)
+ zha_gateway = await Gateway.async_from_config(zha_lib_data)
+
# Load and cache device trigger information early
device_registry = dr.async_get(hass)
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
@@ -140,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
try:
- zha_gateway = await Gateway.async_from_config(zha_lib_data)
+ await zha_gateway.async_initialize()
except NetworkSettingsInconsistent as exc:
await warn_on_inconsistent_network_settings(
hass,
@@ -202,6 +210,15 @@ async def async_shutdown(_: Event) -> None:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown)
)
+ @callback
+ def update_config(event: Event) -> None:
+ """Handle Core config update."""
+ zha_gateway.config.local_timezone = ZoneInfo(hass.config.time_zone)
+
+ config_entry.async_on_unload(
+ hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_config)
+ )
+
await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities()
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES)
diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py
index bc4738d032a0a1..f276630dfeeadc 100644
--- a/homeassistant/components/zha/diagnostics.py
+++ b/homeassistant/components/zha/diagnostics.py
@@ -7,7 +7,7 @@
from typing import Any
from zha.application.const import (
- ATTR_ATTRIBUTE_NAME,
+ ATTR_ATTRIBUTE,
ATTR_DEVICE_TYPE,
ATTR_IEEE,
ATTR_IN_CLUSTERS,
@@ -158,27 +158,15 @@ def get_endpoint_cluster_attr_data(zha_device: Device) -> dict:
def get_cluster_attr_data(cluster: Cluster) -> dict:
"""Return cluster attribute data."""
- unsupported_attributes = {}
- for u_attr in cluster.unsupported_attributes:
- try:
- u_attr_def = cluster.find_attribute(u_attr)
- unsupported_attributes[f"0x{u_attr_def.id:04x}"] = {
- ATTR_ATTRIBUTE_NAME: u_attr_def.name
- }
- except KeyError:
- if isinstance(u_attr, int):
- unsupported_attributes[f"0x{u_attr:04x}"] = {}
- else:
- unsupported_attributes[u_attr] = {}
-
return {
ATTRIBUTES: {
f"0x{attr_id:04x}": {
- ATTR_ATTRIBUTE_NAME: attr_def.name,
- ATTR_VALUE: attr_value,
+ ATTR_ATTRIBUTE: repr(attr_def),
+ ATTR_VALUE: cluster.get(attr_def.name),
}
for attr_id, attr_def in cluster.attributes.items()
- if (attr_value := cluster.get(attr_def.name)) is not None
},
- UNSUPPORTED_ATTRIBUTES: unsupported_attributes,
+ UNSUPPORTED_ATTRIBUTES: sorted(
+ cluster.unsupported_attributes, key=lambda v: (isinstance(v, str), v)
+ ),
}
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 6db0ffad964c08..348e545f1c4fcb 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -62,7 +62,7 @@ def __init__(self, entity_data: EntityData, *args, **kwargs) -> None:
@property
def available(self) -> bool:
"""Return entity availability."""
- return self.entity_data.device_proxy.device.available
+ return self.entity_data.entity.available
@property
def device_info(self) -> DeviceInfo:
diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py
index 0691e2429d1d5c..35a794e863188d 100644
--- a/homeassistant/components/zha/helpers.py
+++ b/homeassistant/components/zha/helpers.py
@@ -15,6 +15,7 @@
import time
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, ParamSpec, TypeVar, cast
+from zoneinfo import ZoneInfo
import voluptuous as vol
from zha.application.const import (
@@ -1273,6 +1274,7 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
quirks_configuration=quirks_config,
device_overrides=overrides_config,
),
+ local_timezone=ZoneInfo(hass.config.time_zone),
)
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index d2d328cc84b465..a5e57fcb1ec020 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.24"],
+ "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.31"],
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py
index 2b7a65f4997827..82c30b7678a579 100644
--- a/homeassistant/components/zha/radio_manager.py
+++ b/homeassistant/components/zha/radio_manager.py
@@ -178,7 +178,6 @@ async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]:
app_config[CONF_DEVICE] = self.device_settings
app_config[CONF_NWK_BACKUP_ENABLED] = False
app_config[CONF_USE_THREAD] = False
- app_config = self.radio_type.controller.SCHEMA(app_config)
app = await self.radio_type.controller.new(
app_config, auto_form=False, start_radio=False
diff --git a/homeassistant/config.py b/homeassistant/config.py
index 18c833d4c75211..948ab342e79e0e 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -817,9 +817,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non
This method is a coroutine.
"""
- # CORE_CONFIG_SCHEMA is not async safe since it uses vol.IsDir
- # so we need to run it in an executor job.
- config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config)
+ config = CORE_CONFIG_SCHEMA(config)
# Only load auth during startup.
if not hasattr(hass, "auth"):
@@ -1535,15 +1533,9 @@ async def async_process_component_config(
return IntegrationConfigInfo(None, config_exceptions)
# No custom config validator, proceed with schema validation
- if config_schema := getattr(component, "CONFIG_SCHEMA", None):
+ if hasattr(component, "CONFIG_SCHEMA"):
try:
- if domain in config:
- # cv.isdir, cv.isfile, cv.isdevice are not async
- # friendly so we need to run this in executor
- schema = await hass.async_add_executor_job(config_schema, config)
- else:
- schema = config_schema(config)
- return IntegrationConfigInfo(schema, [])
+ return IntegrationConfigInfo(component.CONFIG_SCHEMA(config), [])
except vol.Invalid as exc:
exc_info = ConfigExceptionInfo(
exc,
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index aa0113cd7cead7..75b0631339fa44 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -1245,10 +1245,10 @@ async def async_wait_import_flow_initialized(self, handler: str) -> None:
@callback
def _async_has_other_discovery_flows(self, flow_id: str) -> bool:
"""Check if there are any other discovery flows in progress."""
- return any(
- flow.context["source"] in DISCOVERY_SOURCES and flow.flow_id != flow_id
- for flow in self._progress.values()
- )
+ for flow in self._progress.values():
+ if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES:
+ return True
+ return False
async def async_init(
self, handler: str, *, context: dict[str, Any] | None = None, data: Any = None
@@ -1699,12 +1699,12 @@ def async_has_entries(
entries = self._entries.get_entries_for_domain(domain)
if include_ignore and include_disabled:
return bool(entries)
- return any(
- entry
- for entry in entries
- if (include_ignore or entry.source != SOURCE_IGNORE)
- and (include_disabled or not entry.disabled_by)
- )
+ for entry in entries:
+ if (include_ignore or entry.source != SOURCE_IGNORE) and (
+ include_disabled or not entry.disabled_by
+ ):
+ return True
+ return False
@callback
def async_entries(
diff --git a/homeassistant/const.py b/homeassistant/const.py
index d0f1d4555d4eaf..891cc0cc023ee6 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -23,7 +23,7 @@
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
-MINOR_VERSION: Final = 8
+MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
@@ -113,6 +113,7 @@ class Platform(StrEnum):
# #### CONFIG ####
CONF_ABOVE: Final = "above"
CONF_ACCESS_TOKEN: Final = "access_token"
+CONF_ACTION: Final = "action"
CONF_ADDRESS: Final = "address"
CONF_AFTER: Final = "after"
CONF_ALIAS: Final = "alias"
@@ -221,6 +222,7 @@ class Platform(StrEnum):
CONF_MINIMUM: Final = "minimum"
CONF_MODE: Final = "mode"
CONF_MODEL: Final = "model"
+CONF_MODEL_ID: Final = "model_id"
CONF_MONITORED_CONDITIONS: Final = "monitored_conditions"
CONF_MONITORED_VARIABLES: Final = "monitored_variables"
CONF_NAME: Final = "name"
@@ -564,6 +566,7 @@ class Platform(StrEnum):
ATTR_DEFAULT_NAME: Final = "default_name"
ATTR_MANUFACTURER: Final = "manufacturer"
ATTR_MODEL: Final = "model"
+ATTR_MODEL_ID: Final = "model_id"
ATTR_SERIAL_NUMBER: Final = "serial_number"
ATTR_SUGGESTED_AREA: Final = "suggested_area"
ATTR_SW_VERSION: Final = "sw_version"
diff --git a/homeassistant/core.py b/homeassistant/core.py
index 5d223b9f19fa47..1050d25ee715e6 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -101,6 +101,7 @@
from .util.async_ import (
cancelling,
create_eager_task,
+ get_scheduled_timer_handles,
run_callback_threadsafe,
shutdown_run_callback_threadsafe,
)
@@ -1227,8 +1228,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
def _cancel_cancellable_timers(self) -> None:
"""Cancel timer handles marked as cancellable."""
- handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] # noqa: SLF001
- for handle in handles:
+ for handle in get_scheduled_timer_handles(self.loop):
if (
not handle.cancelled()
and (args := handle._args) # noqa: SLF001
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index d350a58f3c6d8d..c3fe4af4a76d09 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -53,6 +53,7 @@
"androidtv_remote",
"anova",
"anthemav",
+ "anthropic",
"aosmith",
"apcupsd",
"apple_tv",
@@ -150,6 +151,7 @@
"efergy",
"electrasmart",
"electric_kiwi",
+ "elevenlabs",
"elgato",
"elkm1",
"elmax",
@@ -323,7 +325,6 @@
"local_ip",
"local_todo",
"locative",
- "logi_circle",
"lookin",
"loqed",
"luftdaten",
@@ -384,6 +385,7 @@
"nextdns",
"nfandroidtv",
"nibe_heatpump",
+ "nice_go",
"nightscout",
"nina",
"nmap_tracker",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 4de325a0c6e234..7df27aa5e68af0 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -200,12 +200,6 @@
"amazon": {
"name": "Amazon",
"integrations": {
- "alexa": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_push",
- "name": "Amazon Alexa"
- },
"amazon_polly": {
"integration_type": "hub",
"config_flow": false,
@@ -315,6 +309,12 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "anthropic": {
+ "name": "Anthropic Conversation",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"anwb_energie": {
"name": "ANWB Energie",
"integration_type": "virtual",
@@ -455,6 +455,11 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "artsound": {
+ "name": "ArtSound",
+ "integration_type": "virtual",
+ "supported_by": "linkplay"
+ },
"aruba": {
"name": "Aruba",
"integrations": {
@@ -490,29 +495,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
- "assist_pipeline": {
- "name": "Assist pipeline",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_push"
- },
- "asterisk": {
- "name": "Asterisk",
- "integrations": {
- "asterisk_cdr": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_polling",
- "name": "Asterisk Call Detail Records"
- },
- "asterisk_mbox": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "local_push",
- "name": "Asterisk Voicemail"
- }
- }
- },
"asuswrt": {
"name": "ASUSWRT",
"integration_type": "hub",
@@ -1516,6 +1498,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "elevenlabs": {
+ "name": "ElevenLabs",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"elgato": {
"name": "Elgato",
"integrations": {
@@ -1804,11 +1792,6 @@
"ffmpeg": {
"name": "FFmpeg",
"integrations": {
- "ffmpeg": {
- "integration_type": "hub",
- "config_flow": false,
- "name": "FFmpeg"
- },
"ffmpeg_motion": {
"integration_type": "hub",
"config_flow": false,
@@ -2242,12 +2225,6 @@
"google": {
"name": "Google",
"integrations": {
- "google_assistant": {
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "cloud_push",
- "name": "Google Assistant"
- },
"google_assistant_sdk": {
"integration_type": "service",
"config_flow": true,
@@ -3367,12 +3344,6 @@
"config_flow": false,
"iot_class": "cloud_push"
},
- "logi_circle": {
- "name": "Logi Circle",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "cloud_polling"
- },
"logitech": {
"name": "Logitech",
"integrations": {
@@ -3477,12 +3448,6 @@
"config_flow": true,
"iot_class": "cloud_push"
},
- "manual": {
- "name": "Manual Alarm Control Panel",
- "integration_type": "hub",
- "config_flow": false,
- "iot_class": "calculated"
- },
"marantz": {
"name": "Marantz",
"integration_type": "virtual",
@@ -4064,6 +4029,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "nice_go": {
+ "name": "Nice G.O.",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push"
+ },
"nightscout": {
"name": "Nightscout",
"integration_type": "hub",
@@ -4599,6 +4570,11 @@
"config_flow": false,
"iot_class": "local_push"
},
+ "pinecil": {
+ "name": "Pinecil",
+ "integration_type": "virtual",
+ "supported_by": "iron_os"
+ },
"ping": {
"name": "Ping (ICMP)",
"integration_type": "hub",
@@ -6002,10 +5978,6 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
- "tag": {
- "integration_type": "hub",
- "config_flow": false
- },
"tailscale": {
"name": "Tailscale",
"integration_type": "hub",
@@ -6809,11 +6781,6 @@
}
}
},
- "webhook": {
- "name": "Webhook",
- "integration_type": "hub",
- "config_flow": false
- },
"webmin": {
"name": "Webmin",
"integration_type": "device",
@@ -7250,6 +7217,12 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "manual": {
+ "name": "Manual Alarm Control Panel",
+ "integration_type": "helper",
+ "config_flow": false,
+ "iot_class": "calculated"
+ },
"min_max": {
"integration_type": "helper",
"config_flow": true,
@@ -7354,7 +7327,6 @@
"shopping_list",
"sun",
"switch_as_x",
- "tag",
"threshold",
"time_date",
"tod",
diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py
index 6f52569c38c0ee..d61f889d4b5e6d 100644
--- a/homeassistant/helpers/aiohttp_client.py
+++ b/homeassistant/helpers/aiohttp_client.py
@@ -285,6 +285,21 @@ def _make_key(
return (verify_ssl, family)
+class HomeAssistantTCPConnector(aiohttp.TCPConnector):
+ """Home Assistant TCP Connector.
+
+ Same as aiohttp.TCPConnector but with a longer cleanup_closed timeout.
+
+ By default the cleanup_closed timeout is 2 seconds. This is too short
+ for Home Assistant since we churn through a lot of connections. We set
+ it to 60 seconds to reduce the overhead of aborting TLS connections
+ that are likely already closed.
+ """
+
+ # abort transport after 60 seconds (cleanup broken connections)
+ _cleanup_closed_period = 60.0
+
+
@callback
def _async_get_connector(
hass: HomeAssistant,
@@ -306,7 +321,7 @@ def _async_get_connector(
else:
ssl_context = ssl_util.get_default_no_verify_context()
- connector = aiohttp.TCPConnector(
+ connector = HomeAssistantTCPConnector(
family=family,
enable_cleanup_closed=ENABLE_CLEANUP_CLOSED,
ssl=ssl_context,
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 3438336dbfa9fa..629cdeef9429e6 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -60,7 +60,7 @@
from . import config_validation as cv, entity_registry as er
from .sun import get_astral_event_date
-from .template import Template, attach as template_attach, render_complex
+from .template import Template, render_complex
from .trace import (
TraceElement,
trace_append_element,
@@ -510,9 +510,6 @@ def if_numeric_state(
hass: HomeAssistant, variables: TemplateVarsType = None
) -> bool:
"""Test numeric state condition."""
- if value_template is not None:
- value_template.hass = hass
-
errors = []
for index, entity_id in enumerate(entity_ids):
try:
@@ -630,7 +627,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType:
@trace_condition_function
def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
- template_attach(hass, for_period)
errors = []
result: bool = match != ENTITY_MATCH_ANY
for index, entity_id in enumerate(entity_ids):
@@ -792,8 +788,6 @@ def async_template_from_config(config: ConfigType) -> ConditionCheckerType:
@trace_condition_function
def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Validate template based if-condition."""
- value_template.hass = hass
-
return async_template(hass, value_template, variables)
return template_if
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index a28c81e6da9115..ed3eca6e316160 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -34,6 +34,7 @@
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
CONF_ABOVE,
+ CONF_ACTION,
CONF_ALIAS,
CONF_ATTRIBUTE,
CONF_BELOW,
@@ -769,9 +770,9 @@ def socket_timeout(value: Any | None) -> object:
float_value = float(value)
if float_value > 0.0:
return float_value
- raise vol.Invalid("Invalid socket timeout value. float > 0.0 required.")
except Exception as err:
raise vol.Invalid(f"Invalid socket timeout: {err}") from err
+ raise vol.Invalid("Invalid socket timeout value. float > 0.0 required.")
def url(
@@ -1274,7 +1275,7 @@ def _make_entity_service_schema(schema: dict, extra: int) -> vol.Schema:
def make_entity_service_schema(
- schema: dict, *, extra: int = vol.PREVENT_EXTRA
+ schema: dict | None, *, extra: int = vol.PREVENT_EXTRA
) -> vol.Schema:
"""Create an entity service schema."""
if not schema and extra == vol.PREVENT_EXTRA:
@@ -1282,7 +1283,7 @@ def make_entity_service_schema(
# the base schema and avoid compiling a new schema which is the case
# for ~50% of services.
return BASE_ENTITY_SCHEMA
- return _make_entity_service_schema(schema, extra)
+ return _make_entity_service_schema(schema or {}, extra)
SCRIPT_CONVERSATION_RESPONSE_SCHEMA = vol.Any(template, None)
@@ -1325,11 +1326,30 @@ def script_action(value: Any) -> dict:
}
)
+
+def _backward_compat_service_schema(value: Any | None) -> Any:
+ """Backward compatibility for service schemas."""
+
+ if not isinstance(value, dict):
+ return value
+
+ # `service` has been renamed to `action`
+ if CONF_SERVICE in value:
+ if CONF_ACTION in value:
+ raise vol.Invalid(
+ "Cannot specify both 'service' and 'action'. Please use 'action' only."
+ )
+ value[CONF_ACTION] = value.pop(CONF_SERVICE)
+
+ return value
+
+
SERVICE_SCHEMA = vol.All(
+ _backward_compat_service_schema,
vol.Schema(
{
**SCRIPT_ACTION_BASE_SCHEMA,
- vol.Exclusive(CONF_SERVICE, "service name"): vol.Any(
+ vol.Exclusive(CONF_ACTION, "service name"): vol.Any(
service, dynamic_template
),
vol.Exclusive(CONF_SERVICE_TEMPLATE, "service name"): vol.Any(
@@ -1348,7 +1368,7 @@ def script_action(value: Any) -> dict:
vol.Remove("metadata"): dict,
}
),
- has_at_least_one_key(CONF_SERVICE, CONF_SERVICE_TEMPLATE),
+ has_at_least_one_key(CONF_ACTION, CONF_SERVICE_TEMPLATE),
)
NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any(
@@ -1844,6 +1864,7 @@ def _base_trigger_validator(value: Any) -> Any:
CONF_WAIT_FOR_TRIGGER: SCRIPT_ACTION_WAIT_FOR_TRIGGER,
CONF_VARIABLES: SCRIPT_ACTION_VARIABLES,
CONF_IF: SCRIPT_ACTION_IF,
+ CONF_ACTION: SCRIPT_ACTION_CALL_SERVICE,
CONF_SERVICE: SCRIPT_ACTION_CALL_SERVICE,
CONF_SERVICE_TEMPLATE: SCRIPT_ACTION_CALL_SERVICE,
CONF_STOP: SCRIPT_ACTION_STOP,
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index 0034eb1c6fcee1..826693de133265 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -11,6 +11,7 @@
from typing import Any, Generic
from typing_extensions import TypeVar
+import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
@@ -258,14 +259,28 @@ async def handle_service(
def async_register_entity_service(
self,
name: str,
- schema: VolDictType | VolSchemaType,
+ schema: VolDictType | VolSchemaType | None,
func: str | Callable[..., Any],
required_features: list[int] | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
) -> None:
"""Register an entity service."""
- if isinstance(schema, dict):
+ if schema is None or isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema)
+ # Do a sanity check to check this is a valid entity service schema,
+ # the check could be extended to require All/Any to have sub schema(s)
+ # with all entity service fields
+ elif (
+ # Don't check All/Any
+ not isinstance(schema, (vol.All, vol.Any))
+ # Don't check All/Any wrapped in schema
+ and not isinstance(schema.schema, (vol.All, vol.Any))
+ and any(key not in schema.schema for key in cv.ENTITY_SERVICE_FIELDS)
+ ):
+ raise HomeAssistantError(
+ "The schema does not include all required keys: "
+ f"{", ".join(str(key) for key in cv.ENTITY_SERVICE_FIELDS)}"
+ )
service_func: str | HassJob[..., Any]
service_func = func if isinstance(func, str) else HassJob(func)
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index d868e582f8f190..ec177fbf316aa8 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -10,6 +10,8 @@
from logging import Logger, getLogger
from typing import TYPE_CHECKING, Any, Protocol
+import voluptuous as vol
+
from homeassistant import config_entries
from homeassistant.const import (
ATTR_RESTORED,
@@ -997,8 +999,22 @@ def async_register_entity_service(
if self.hass.services.has_service(self.platform_name, name):
return
- if isinstance(schema, dict):
+ if schema is None or isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema)
+ # Do a sanity check to check this is a valid entity service schema,
+ # the check could be extended to require All/Any to have sub schema(s)
+ # with all entity service fields
+ elif (
+ # Don't check All/Any
+ not isinstance(schema, (vol.All, vol.Any))
+ # Don't check All/Any wrapped in schema
+ and not isinstance(schema.schema, (vol.All, vol.Any))
+ and any(key not in schema.schema for key in cv.ENTITY_SERVICE_FIELDS)
+ ):
+ raise HomeAssistantError(
+ "The schema does not include all required keys: "
+ f"{", ".join(str(key) for key in cv.ENTITY_SERVICE_FIELDS)}"
+ )
service_func: str | HassJob[..., Any]
service_func = func if isinstance(func, str) else HassJob(func)
diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py
index 24b65cba82a6be..1eaa0fb140454f 100644
--- a/homeassistant/helpers/entityfilter.py
+++ b/homeassistant/helpers/entityfilter.py
@@ -4,7 +4,8 @@
from collections.abc import Callable
import fnmatch
-from functools import lru_cache
+from functools import lru_cache, partial
+import operator
import re
import voluptuous as vol
@@ -195,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists(
# Case 1 - No filter
# - All entities included
if not have_include and not have_exclude:
- return lambda entity_id: True
+ return bool
# Case 2 - Only includes
# - Entity listed in entities include: include
@@ -280,4 +281,4 @@ def entity_filter_4b(entity_id: str) -> bool:
# Case 6 - No Domain and/or glob includes or excludes
# - Entity listed in entities include: include
# - Otherwise: exclude
- return lambda entity_id: entity_id in include_e
+ return partial(operator.contains, include_e)
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 207dd024b6aad7..38f461d8d7a9c4 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -976,8 +976,6 @@ def __init__(
self.hass = hass
self._job = HassJob(action, f"track template result {track_templates}")
- for track_template_ in track_templates:
- track_template_.template.hass = hass
self._track_templates = track_templates
self._has_super_template = has_super_template
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index eeb160934ff4b6..be9b57bf81423c 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -7,7 +7,7 @@
from collections.abc import Callable, Collection, Coroutine, Iterable
import dataclasses
from dataclasses import dataclass, field
-from enum import Enum, auto
+from enum import Enum, StrEnum, auto
from functools import cached_property
from itertools import groupby
import logging
@@ -820,6 +820,7 @@ def __init__(
required_states: set[str] | None = None,
description: str | None = None,
platforms: set[str] | None = None,
+ device_classes: set[type[StrEnum]] | None = None,
) -> None:
"""Create Service Intent Handler."""
self.intent_type = intent_type
@@ -829,6 +830,7 @@ def __init__(
self.required_states = required_states
self.description = description
self.platforms = platforms
+ self.device_classes = device_classes
self.required_slots: _IntentSlotsType = {}
if required_slots:
@@ -851,13 +853,38 @@ def __init__(
@cached_property
def slot_schema(self) -> dict:
"""Return a slot schema."""
+ domain_validator = (
+ vol.In(list(self.required_domains)) if self.required_domains else cv.string
+ )
slot_schema = {
vol.Any("name", "area", "floor"): non_empty_string,
- vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional("preferred_area_id"): cv.string,
- vol.Optional("preferred_floor_id"): cv.string,
+ vol.Optional("domain"): vol.All(cv.ensure_list, [domain_validator]),
}
+ if self.device_classes:
+ # The typical way to match enums is with vol.Coerce, but we build a
+ # flat list to make the API simpler to describe programmatically
+ flattened_device_classes = vol.In(
+ [
+ device_class.value
+ for device_class_enum in self.device_classes
+ for device_class in device_class_enum
+ ]
+ )
+ slot_schema.update(
+ {
+ vol.Optional("device_class"): vol.All(
+ cv.ensure_list,
+ [flattened_device_classes],
+ )
+ }
+ )
+
+ slot_schema.update(
+ {
+ vol.Optional("preferred_area_id"): cv.string,
+ vol.Optional("preferred_floor_id"): cv.string,
+ }
+ )
if self.required_slots:
slot_schema.update(
@@ -910,9 +937,6 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse:
if "domain" in slots:
domains = set(slots["domain"]["value"])
- if self.required_domains:
- # Must be a subset of intent's required domain(s)
- domains.intersection_update(self.required_domains)
if "device_class" in slots:
device_classes = set(slots["device_class"]["value"])
@@ -1120,6 +1144,7 @@ def __init__(
required_states: set[str] | None = None,
description: str | None = None,
platforms: set[str] | None = None,
+ device_classes: set[type[StrEnum]] | None = None,
) -> None:
"""Create service handler."""
super().__init__(
@@ -1132,6 +1157,7 @@ def __init__(
required_states=required_states,
description=description,
platforms=platforms,
+ device_classes=device_classes,
)
self.domain = domain
self.service = service
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index 8ad576b7ea5ea7..e37aa0c532d677 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -167,7 +167,7 @@ class APIInstance:
async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType:
"""Call a LLM tool, validate args and return the response."""
async_conversation_trace_append(
- ConversationTraceEventType.LLM_TOOL_CALL,
+ ConversationTraceEventType.TOOL_CALL,
{"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args},
)
@@ -677,6 +677,19 @@ def on_homeassistant_close(event: Event) -> None:
self.parameters = vol.Schema(schema)
+ aliases: list[str] = []
+ if entity_entry.name:
+ aliases.append(entity_entry.name)
+ if entity_entry.aliases:
+ aliases.extend(entity_entry.aliases)
+ if aliases:
+ if self.description:
+ self.description = (
+ self.description + ". Aliases: " + str(list(aliases))
+ )
+ else:
+ self.description = "Aliases: " + str(list(aliases))
+
parameters_cache[entity_entry.unique_id] = (
self.description,
self.parameters,
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index a1b885d0c5263c..26a9b6e069e3e4 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -669,7 +669,6 @@ async def _async_wait_template_step(self) -> None:
trace_set_result(wait=self._variables["wait"])
wait_template = self._action[CONF_WAIT_TEMPLATE]
- wait_template.hass = self._hass
# check if condition already okay
if condition.async_template(self._hass, wait_template, self._variables, False):
@@ -1429,7 +1428,6 @@ def __init__(
self._hass = hass
self.sequence = sequence
- template.attach(hass, self.sequence)
self.name = name
self.unique_id = f"{domain}.{name}-{id(self)}"
self.domain = domain
@@ -1459,8 +1457,6 @@ def __init__(
self._sequence_scripts: dict[int, Script] = {}
self.variables = variables
self._variables_dynamic = template.is_complex(variables)
- if self._variables_dynamic:
- template.attach(hass, variables)
self._copy_variables_on_run = copy_variables
@property
diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py
index 043101b9b86911..2b4507abd640a6 100644
--- a/homeassistant/helpers/script_variables.py
+++ b/homeassistant/helpers/script_variables.py
@@ -36,7 +36,6 @@ def async_render(
"""
if self._has_template is None:
self._has_template = template.is_complex(self.variables)
- template.attach(hass, self.variables)
if not self._has_template:
if render_as_defaults:
diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py
index 5a542657d103d6..025b8de88963c0 100644
--- a/homeassistant/helpers/selector.py
+++ b/homeassistant/helpers/selector.py
@@ -725,6 +725,7 @@ class DurationSelectorConfig(TypedDict, total=False):
"""Class to represent a duration selector config."""
enable_day: bool
+ enable_millisecond: bool
allow_negative: bool
@@ -739,6 +740,8 @@ class DurationSelector(Selector[DurationSelectorConfig]):
# Enable day field in frontend. A selection with `days` set is allowed
# even if `enable_day` is not set
vol.Optional("enable_day"): cv.boolean,
+ # Enable millisecond field in frontend.
+ vol.Optional("enable_millisecond"): cv.boolean,
# Allow negative durations. Will default to False in HA Core 2025.6.0.
vol.Optional("allow_negative"): cv.boolean,
}
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 35c682437cb13b..be4974906bbfbc 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -20,8 +20,8 @@
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
+ CONF_ACTION,
CONF_ENTITY_ID,
- CONF_SERVICE,
CONF_SERVICE_DATA,
CONF_SERVICE_DATA_TEMPLATE,
CONF_SERVICE_TEMPLATE,
@@ -358,14 +358,13 @@ def async_prepare_call_from_config(
f"Invalid config for calling service: {ex}"
) from ex
- if CONF_SERVICE in config:
- domain_service = config[CONF_SERVICE]
+ if CONF_ACTION in config:
+ domain_service = config[CONF_ACTION]
else:
domain_service = config[CONF_SERVICE_TEMPLATE]
if isinstance(domain_service, template.Template):
try:
- domain_service.hass = hass
domain_service = domain_service.async_render(variables)
domain_service = cv.service(domain_service)
except TemplateError as ex:
@@ -384,10 +383,8 @@ def async_prepare_call_from_config(
conf = config[CONF_TARGET]
try:
if isinstance(conf, template.Template):
- conf.hass = hass
target.update(conf.async_render(variables))
else:
- template.attach(hass, conf)
target.update(template.render_complex(conf, variables))
if CONF_ENTITY_ID in target:
@@ -413,7 +410,6 @@ def async_prepare_call_from_config(
if conf not in config:
continue
try:
- template.attach(hass, config[conf])
render = template.render_complex(config[conf], variables)
if not isinstance(render, dict):
raise HomeAssistantError(
diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py
index 7b1c4ab807814f..7f8ad41d7bb5e5 100644
--- a/homeassistant/helpers/trigger_template_entity.py
+++ b/homeassistant/helpers/trigger_template_entity.py
@@ -30,7 +30,7 @@
from . import config_validation as cv
from .entity import Entity
-from .template import attach as template_attach, render_complex
+from .template import render_complex
from .typing import ConfigType
CONF_AVAILABILITY = "availability"
@@ -157,11 +157,6 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return extra attributes."""
return self._rendered.get(CONF_ATTRIBUTES)
- async def async_added_to_hass(self) -> None:
- """Handle being added to Home Assistant."""
- await super().async_added_to_hass()
- template_attach(self.hass, self._config)
-
def _set_unique_id(self, unique_id: str | None) -> None:
"""Set unique id."""
self._unique_id = unique_id
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 70c05f35c33122..8cad4d2037a46f 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2
aiodiscover==2.1.0
aiodns==3.2.0
aiohttp-fast-zlib==0.1.1
-aiohttp==3.10.0rc0
+aiohttp==3.10.3
aiohttp_cors==0.7.0
aiozoneinfo==0.2.1
astral==2.2
@@ -18,7 +18,7 @@ bleak-retry-connector==3.5.0
bleak==0.22.2
bluetooth-adapters==0.19.3
bluetooth-auto-recovery==1.4.2
-bluetooth-data-tools==1.19.3
+bluetooth-data-tools==1.19.4
cached_ipaddress==0.3.0
certifi>=2021.5.30
ciso8601==2.3.1
@@ -31,36 +31,36 @@ habluetooth==3.1.3
hass-nabucasa==0.81.1
hassil==1.7.4
home-assistant-bluetooth==1.12.2
-home-assistant-frontend==20240719.0
-home-assistant-intents==2024.7.29
+home-assistant-frontend==20240809.0
+home-assistant-intents==2024.8.7
httpx==0.27.0
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.10.6
+orjson==3.10.7
packaging>=23.1
paho-mqtt==1.6.1
Pillow==10.4.0
pip>=21.3.1
psutil-home-assistant==0.0.1
-PyJWT==2.8.0
+PyJWT==2.9.0
+pymicro-vad==1.0.1
PyNaCl==1.5.0
pyOpenSSL==24.2.1
pyserial==3.5
python-slugify==8.0.4
PyTurboJPEG==1.7.1
pyudev==0.24.1
-PyYAML==6.0.1
+PyYAML==6.0.2
requests==2.32.3
SQLAlchemy==2.0.31
typing-extensions>=4.12.2,<5.0
-ulid-transform==0.10.1
+ulid-transform==0.13.1
urllib3>=1.26.5,<2
voluptuous-openapi==0.0.5
voluptuous-serialize==2.6.0
voluptuous==0.15.2
-webrtc-noise-gain==1.2.3
yarl==1.9.4
zeroconf==0.132.2
@@ -79,11 +79,6 @@ grpcio==1.59.0
grpcio-status==1.59.0
grpcio-reflection==1.59.0
-# libcst >=0.4.0 requires a newer Rust than we currently have available,
-# thus our wheels builds fail. This pins it to the last working version,
-# which at this point satisfies our needs.
-libcst==0.3.23
-
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -98,11 +93,6 @@ enum34==1000000000.0.0
typing==1000000000.0.0
uuid==1000000000.0.0
-# regex causes segfault with version 2021.8.27
-# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error
-# This is fixed in 2021.8.28
-regex==2021.8.28
-
# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
@@ -152,7 +142,7 @@ pyOpenSSL>=24.0.0
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==4.25.1
+protobuf==4.25.4
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index 34bc536502f21c..b769d385a4f055 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -4,10 +4,8 @@
import argparse
import asyncio
-import collections
from collections.abc import Callable
from contextlib import suppress
-import json
import logging
from timeit import default_timer as timer
@@ -18,7 +16,7 @@
async_track_state_change,
async_track_state_change_event,
)
-from homeassistant.helpers.json import JSON_DUMP, JSONEncoder
+from homeassistant.helpers.json import JSON_DUMP
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
# mypy: no-warn-return-any
@@ -310,48 +308,3 @@ async def json_serialize_states(hass):
start = timer()
JSON_DUMP(states)
return timer() - start
-
-
-def _create_state_changed_event_from_old_new(
- entity_id, event_time_fired, old_state, new_state
-):
- """Create a state changed event from a old and new state."""
- attributes = {}
- if new_state is not None:
- attributes = new_state.get("attributes")
- attributes_json = json.dumps(attributes, cls=JSONEncoder)
- if attributes_json == "null":
- attributes_json = "{}"
- row = collections.namedtuple(
- "Row",
- [
- "event_type"
- "event_data"
- "time_fired"
- "context_id"
- "context_user_id"
- "state"
- "entity_id"
- "domain"
- "attributes"
- "state_id",
- "old_state_id",
- ],
- )
-
- row.event_type = EVENT_STATE_CHANGED
- row.event_data = "{}"
- row.attributes = attributes_json
- row.time_fired = event_time_fired
- row.state = new_state and new_state.get("state")
- row.entity_id = entity_id
- row.domain = entity_id and core.split_entity_id(entity_id)[0]
- row.context_id = None
- row.context_user_id = None
- row.old_state_id = old_state and 1
- row.state_id = new_state and 1
-
- # pylint: disable-next=import-outside-toplevel
- from homeassistant.components import logbook
-
- return logbook.LazyEventPartialState(row, {})
diff --git a/homeassistant/scripts/macos/__init__.py b/homeassistant/scripts/macos/__init__.py
index f629492ec39842..0bf88da81dc61f 100644
--- a/homeassistant/scripts/macos/__init__.py
+++ b/homeassistant/scripts/macos/__init__.py
@@ -44,7 +44,7 @@ def uninstall_osx():
print("Home Assistant has been uninstalled.")
-def run(args):
+def run(args: list[str]) -> int:
"""Handle OSX commandline script."""
commands = "install", "uninstall", "restart"
if not args or args[0] not in commands:
@@ -63,3 +63,5 @@ def run(args):
time.sleep(0.5)
install_osx()
return 0
+
+ raise ValueError(f"Invalid command {args[0]}")
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index 12dd17b289c3f6..102c48e1d0741a 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -281,19 +281,20 @@ async def _async_setup_component(
integration = await loader.async_get_integration(hass, domain)
except loader.IntegrationNotFound:
_log_error_setup_error(hass, domain, None, "Integration not found.")
- ir.async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"integration_not_found.{domain}",
- is_fixable=True,
- issue_domain=HOMEASSISTANT_DOMAIN,
- severity=IssueSeverity.ERROR,
- translation_key="integration_not_found",
- translation_placeholders={
- "domain": domain,
- },
- data={"domain": domain},
- )
+ if not hass.config.safe_mode:
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"integration_not_found.{domain}",
+ is_fixable=True,
+ issue_domain=HOMEASSISTANT_DOMAIN,
+ severity=IssueSeverity.ERROR,
+ translation_key="integration_not_found",
+ translation_placeholders={
+ "domain": domain,
+ },
+ data={"domain": domain},
+ )
return False
log_error = partial(_log_error_setup_error, hass, domain, integration)
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
index f2dc1291324a0d..dcb788f0685e31 100644
--- a/homeassistant/util/async_.py
+++ b/homeassistant/util/async_.py
@@ -2,7 +2,15 @@
from __future__ import annotations
-from asyncio import AbstractEventLoop, Future, Semaphore, Task, gather, get_running_loop
+from asyncio import (
+ AbstractEventLoop,
+ Future,
+ Semaphore,
+ Task,
+ TimerHandle,
+ gather,
+ get_running_loop,
+)
from collections.abc import Awaitable, Callable, Coroutine
import concurrent.futures
import logging
@@ -124,3 +132,9 @@ def shutdown_run_callback_threadsafe(loop: AbstractEventLoop) -> None:
python is going to exit.
"""
setattr(loop, _SHUTDOWN_RUN_CALLBACK_THREADSAFE, True)
+
+
+def get_scheduled_timer_handles(loop: AbstractEventLoop) -> list[TimerHandle]:
+ """Return a list of scheduled TimerHandles."""
+ handles: list[TimerHandle] = loop._scheduled # type: ignore[attr-defined] # noqa: SLF001
+ return handles
diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py
index 8644f8014b62a8..8a82de9065f142 100644
--- a/homeassistant/util/language.py
+++ b/homeassistant/util/language.py
@@ -137,9 +137,6 @@ def score(
region_idx = pref_regions.index(self.region)
elif dialect.region is not None:
region_idx = pref_regions.index(dialect.region)
- else:
- # Can't happen, but mypy is not smart enough
- raise ValueError
# More preferred regions are at the front.
# Add 1 to boost above a weak match where no regions are set.
diff --git a/mypy.ini b/mypy.ini
index dd7904d798b545..c5478689702733 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -705,26 +705,6 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
-[mypy-homeassistant.components.asterisk_cdr.*]
-check_untyped_defs = true
-disallow_incomplete_defs = true
-disallow_subclassing_any = true
-disallow_untyped_calls = true
-disallow_untyped_decorators = true
-disallow_untyped_defs = true
-warn_return_any = true
-warn_unreachable = true
-
-[mypy-homeassistant.components.asterisk_mbox.*]
-check_untyped_defs = true
-disallow_incomplete_defs = true
-disallow_subclassing_any = true
-disallow_untyped_calls = true
-disallow_untyped_decorators = true
-disallow_untyped_defs = true
-warn_return_any = true
-warn_unreachable = true
-
[mypy-homeassistant.components.asuswrt.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1436,6 +1416,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.elevenlabs.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.elgato.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pyproject.toml b/pyproject.toml
index eac18012ae334e..5f6324bbac5bfc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2024.8.0.dev0"
+version = "2024.9.0.dev0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -24,7 +24,7 @@ classifiers = [
requires-python = ">=3.12.0"
dependencies = [
"aiodns==3.2.0",
- "aiohttp==3.10.0rc0",
+ "aiohttp==3.10.3",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.1.1",
"aiozoneinfo==0.2.1",
@@ -47,21 +47,21 @@ dependencies = [
"ifaddr==0.2.0",
"Jinja2==3.1.4",
"lru-dict==1.3.0",
- "PyJWT==2.8.0",
+ "PyJWT==2.9.0",
# PyJWT has loose dependency. We want the latest one.
"cryptography==43.0.0",
"Pillow==10.4.0",
"pyOpenSSL==24.2.1",
- "orjson==3.10.6",
+ "orjson==3.10.7",
"packaging>=23.1",
"pip>=21.3.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
- "PyYAML==6.0.1",
+ "PyYAML==6.0.2",
"requests==2.32.3",
"SQLAlchemy==2.0.31",
"typing-extensions>=4.12.2,<5.0",
- "ulid-transform==0.10.1",
+ "ulid-transform==0.13.1",
# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503
# Temporary setting an upper bound, to prevent compat issues with urllib3>=2
# https://github.com/home-assistant/core/issues/97248
@@ -312,6 +312,7 @@ disable = [
"no-else-return", # RET505
"broad-except", # BLE001
"protected-access", # SLF001
+ "broad-exception-raised", # TRY002
# "no-self-use", # PLR6301 # Optional plugin, not enabled
# Handled by mypy
@@ -817,15 +818,7 @@ ignore = [
"ISC001",
# Disabled because ruff does not understand type of __all__ generated by a function
- "PLE0605",
-
- # temporarily disabled
- "PT019",
- "PYI024", # Use typing.NamedTuple instead of collections.namedtuple
- "RET503",
- "RET501",
- "TRY002",
- "TRY301"
+ "PLE0605"
]
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
diff --git a/requirements.txt b/requirements.txt
index 5122cb99c41b84..556f9013cee70e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,7 +4,7 @@
# Home Assistant Core
aiodns==3.2.0
-aiohttp==3.10.0rc0
+aiohttp==3.10.3
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.1.1
aiozoneinfo==0.2.1
@@ -23,20 +23,20 @@ home-assistant-bluetooth==1.12.2
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
-PyJWT==2.8.0
+PyJWT==2.9.0
cryptography==43.0.0
Pillow==10.4.0
pyOpenSSL==24.2.1
-orjson==3.10.6
+orjson==3.10.7
packaging>=23.1
pip>=21.3.1
psutil-home-assistant==0.0.1
python-slugify==8.0.4
-PyYAML==6.0.1
+PyYAML==6.0.2
requests==2.32.3
SQLAlchemy==2.0.31
typing-extensions>=4.12.2,<5.0
-ulid-transform==0.10.1
+ulid-transform==0.13.1
urllib3>=1.26.5,<2
voluptuous==0.15.2
voluptuous-serialize==2.6.0
diff --git a/requirements_all.txt b/requirements_all.txt
index c9e5ad7c2647a4..dbd9aae5fda34f 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -4,7 +4,7 @@
-r requirements.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.3
+AEMET-OpenData==0.5.4
# homeassistant.components.honeywell
AIOSomecomfort==0.0.25
@@ -176,10 +176,10 @@ aio-georss-gdacs==0.9
aioairq==0.3.2
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.6.1
+aioairzone-cloud==0.6.2
# homeassistant.components.airzone
-aioairzone==0.8.1
+aioairzone==0.8.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -198,7 +198,7 @@ aioaseko==0.2.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.6.4
+aioautomower==2024.8.0
# homeassistant.components.azure_devops
aioazuredevops==2.1.1
@@ -237,7 +237,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==24.6.2
+aioesphomeapi==25.1.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -255,7 +255,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
-aiohomekit==3.2.1
+aiohomekit==3.2.2
# homeassistant.components.hue
aiohue==4.7.2
@@ -335,7 +335,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
-aioqsw==0.4.0
+aioqsw==0.4.1
# homeassistant.components.rainforest_raven
aioraven==0.7.0
@@ -350,7 +350,7 @@ aioridwell==2024.01.0
aioruckus==0.34
# homeassistant.components.russound_rio
-aiorussound==2.2.0
+aiorussound==2.3.2
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -359,7 +359,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==11.1.0
+aioshelly==11.2.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -374,7 +374,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.switcher_kis
-aioswitcher==3.4.3
+aioswitcher==4.0.2
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -386,7 +386,7 @@ aiotankerkoenig==0.4.1
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==79
+aiounifi==80
# homeassistant.components.vlc_telnet
aiovlc==0.3.2
@@ -407,10 +407,10 @@ aiowebostv==0.4.2
aiowithings==3.0.2
# homeassistant.components.yandex_transport
-aioymaps==1.2.4
+aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.7.1
+airgradient==0.8.0
# homeassistant.components.airly
airly==1.1.0
@@ -451,6 +451,9 @@ anova-wifi==0.17.0
# homeassistant.components.anthemav
anthemav==1.4.1
+# homeassistant.components.anthropic
+anthropic==0.31.2
+
# homeassistant.components.weatherkit
apple_weatherkit==1.1.2
@@ -478,9 +481,6 @@ arris-tg2492lg==2.2.0
# homeassistant.components.ampio
asmog==0.0.6
-# homeassistant.components.asterisk_mbox
-asterisk_mbox==0.5.0
-
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
# homeassistant.components.samsungtv
@@ -600,7 +600,7 @@ bluetooth-auto-recovery==1.4.2
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.19.3
+bluetooth-data-tools==1.19.4
# homeassistant.components.bond
bond-async==0.2.1
@@ -660,6 +660,9 @@ clearpasspy==1.0.2
# homeassistant.components.sinch
clx-sdk-xms==1.0.0
+# homeassistant.components.coinbase
+coinbase-advanced-py==1.2.2
+
# homeassistant.components.coinbase
coinbase==2.1.0
@@ -706,7 +709,7 @@ debugpy==1.8.1
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==8.2.0
+deebot-client==8.3.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -732,7 +735,7 @@ devolo-home-control-api==0.18.3
devolo-plc-api==1.4.1
# homeassistant.components.chacon_dio
-dio-chacon-wifi-api==1.1.0
+dio-chacon-wifi-api==1.2.0
# homeassistant.components.directv
directv==0.4.0
@@ -779,6 +782,9 @@ ecoaliface==0.4.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
+# homeassistant.components.elevenlabs
+elevenlabs==1.6.1
+
# homeassistant.components.elgato
elgato==5.1.2
@@ -980,7 +986,7 @@ google-cloud-texttospeech==2.16.3
google-generativeai==0.6.0
# homeassistant.components.nest
-google-nest-sdm==4.0.5
+google-nest-sdm==4.0.6
# homeassistant.components.google_travel_time
googlemaps==2.5.1
@@ -1004,7 +1010,7 @@ gpiozero==1.6.2
gps3==0.33.3
# homeassistant.components.gree
-greeclimate==1.4.6
+greeclimate==2.1.0
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1090,16 +1096,16 @@ hole==0.8.0
holidays==0.53
# homeassistant.components.frontend
-home-assistant-frontend==20240719.0
+home-assistant-frontend==20240809.0
# homeassistant.components.conversation
-home-assistant-intents==2024.7.29
+home-assistant-intents==2024.8.7
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.1
+homematicip==1.1.2
# homeassistant.components.horizon
horimote==0.4.1
@@ -1213,7 +1219,7 @@ kiwiki-client==0.1.1
knocki==0.3.1
# homeassistant.components.knx
-knx-frontend==2024.7.25.204106
+knx-frontend==2024.8.9.225351
# homeassistant.components.konnected
konnected==1.2.0
@@ -1222,7 +1228,7 @@ konnected==1.2.0
krakenex==2.1.0
# homeassistant.components.lacrosse_view
-lacrosse-view==1.0.1
+lacrosse-view==1.0.2
# homeassistant.components.eufy
lakeside==0.13
@@ -1272,9 +1278,6 @@ lmcloud==1.1.13
# homeassistant.components.google_maps
locationsharinglib==5.0.1
-# homeassistant.components.logi_circle
-logi-circle==0.2.3
-
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1294,7 +1297,7 @@ lw12==0.9.2
lxml==5.1.0
# homeassistant.components.matrix
-matrix-nio==0.24.0
+matrix-nio==0.25.0
# homeassistant.components.maxcube
maxcube-api==0.4.3
@@ -1318,13 +1321,13 @@ melnor-bluetooth==0.0.25
messagebird==1.2.0
# homeassistant.components.meteoalarm
-meteoalertapi==0.3.0
+meteoalertapi==0.3.1
# homeassistant.components.meteo_france
meteofrance-api==1.3.0
# homeassistant.components.mfi
-mficlient==0.3.0
+mficlient==0.5.0
# homeassistant.components.xiaomi_miio
micloud==0.5
@@ -1348,13 +1351,13 @@ moat-ble==0.1.1
moehlenhoff-alpha2==1.3.1
# homeassistant.components.monzo
-monzopy==1.3.0
+monzopy==1.3.2
# homeassistant.components.mopeka
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
-motionblinds==0.6.23
+motionblinds==0.6.24
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.0
@@ -1416,6 +1419,9 @@ nextdns==3.1.0
# homeassistant.components.nibe_heatpump
nibe==2.11.0
+# homeassistant.components.nice_go
+nice-go==0.1.6
+
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
@@ -1466,7 +1472,7 @@ odp-amsterdam==6.0.2
oemthermostat==1.1.1
# homeassistant.components.ollama
-ollama==0.3.0
+ollama==0.3.1
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1499,7 +1505,7 @@ openhomedevice==2.2.0
opensensemap-api==0.2.0
# homeassistant.components.enigma2
-openwebifpy==4.2.5
+openwebifpy==4.2.7
# homeassistant.components.luci
openwrt-luci-rpc==1.1.17
@@ -1647,7 +1653,7 @@ py-madvr2==1.6.29
py-melissa-climate==2.1.4
# homeassistant.components.nextbus
-py-nextbusnext==2.0.3
+py-nextbusnext==2.0.4
# homeassistant.components.nightscout
py-nightscout==1.2.2
@@ -1659,7 +1665,7 @@ py-schluter==0.1.7
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.4.4
+py-synologydsm-api==2.5.2
# homeassistant.components.zabbix
py-zabbix==1.1.7
@@ -1729,7 +1735,7 @@ pyatag==0.3.5.3
pyatmo==8.0.3
# homeassistant.components.apple_tv
-pyatv==0.14.3
+pyatv==0.15.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1774,7 +1780,7 @@ pycmus==0.1.1
pycomfoconnect==0.5.1
# homeassistant.components.coolmaster
-pycoolmasternet-async==0.2.0
+pycoolmasternet-async==0.2.2
# homeassistant.components.microsoft
pycsspeechtts==1.0.8
@@ -1783,7 +1789,7 @@ pycsspeechtts==1.0.8
# pycups==1.9.73
# homeassistant.components.daikin
-pydaikin==2.13.1
+pydaikin==2.13.4
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -1804,7 +1810,7 @@ pydiscovergy==3.0.1
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.6.4
+pydrawise==2024.8.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1837,7 +1843,7 @@ pyeiscp==0.0.7
pyemoncms==0.0.7
# homeassistant.components.enphase_envoy
-pyenphase==1.20.6
+pyenphase==1.22.0
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1864,7 +1870,7 @@ pyfido==2.1.2
pyfireservicerota==0.0.43
# homeassistant.components.flic
-pyflic==2.0.3
+pyflic==2.0.4
# homeassistant.components.futurenow
pyfnip==0.2
@@ -1903,7 +1909,7 @@ pyhiveapi==0.5.16
pyhomematic==0.1.77
# homeassistant.components.homeworks
-pyhomeworks==1.1.0
+pyhomeworks==1.1.1
# homeassistant.components.ialarm
pyialarm==2.2.0
@@ -1939,7 +1945,7 @@ pyisy==3.1.14
pyitachip2ir==0.0.7
# homeassistant.components.jvc_projector
-pyjvcprojector==1.0.11
+pyjvcprojector==1.0.12
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1
@@ -1987,7 +1993,7 @@ pylitejet==0.6.2
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.20.0
+pylutron-caseta==0.21.1
# homeassistant.components.lutron
pylutron==0.2.15
@@ -2007,6 +2013,9 @@ pymelcloud==2.5.9
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.1.0
+# homeassistant.components.assist_pipeline
+pymicro-vad==1.0.1
+
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2062,7 +2071,7 @@ pyombi==0.1.10
pyopenuv==2023.02.0
# homeassistant.components.openweathermap
-pyopenweathermap==0.0.9
+pyopenweathermap==0.1.1
# homeassistant.components.opnsense
pyopnsense==0.4.0
@@ -2091,7 +2100,7 @@ pyownet==0.10.0.post1
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.7.17
+pypck==0.7.21
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -2151,7 +2160,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
-pyschlage==2024.6.0
+pyschlage==2024.8.0
# homeassistant.components.sensibo
pysensibo==1.0.36
@@ -2241,7 +2250,7 @@ python-awair==0.2.4
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
-python-bsblan==0.5.18
+python-bsblan==0.6.2
# homeassistant.components.clementine
python-clementine-remote==1.0.1
@@ -2274,7 +2283,7 @@ python-gitlab==1.6.0
python-homeassistant-analytics==0.7.0
# homeassistant.components.homewizard
-python-homewizard-energy==v6.1.1
+python-homewizard-energy==v6.2.0
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2289,10 +2298,10 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.0.5
+python-kasa[speedups]==0.7.1
# homeassistant.components.linkplay
-python-linkplay==0.0.5
+python-linkplay==0.0.6
# homeassistant.components.lirc
# python-lirc==1.2.3
@@ -2375,7 +2384,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.0.0
# homeassistant.components.v2c
-pytrydan==0.7.0
+pytrydan==0.8.0
# homeassistant.components.usb
pyudev==0.24.1
@@ -2477,7 +2486,7 @@ renault-api==0.2.5
renson-endura-delta==1.7.1
# homeassistant.components.reolink
-reolink-aio==0.9.6
+reolink-aio==0.9.7
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2580,7 +2589,7 @@ sharp_aquos_rc==0.3.2
shodan==1.28.0
# homeassistant.components.simplefin
-simplefin4py==0.0.16
+simplefin4py==0.0.18
# homeassistant.components.sighthound
simplehound==0.3
@@ -2616,7 +2625,7 @@ soco==0.30.4
solaredge-local==0.2.3
# homeassistant.components.solarlog
-solarlog_cli==0.1.5
+solarlog_cli==0.1.6
# homeassistant.components.solax
solax==3.1.1
@@ -2694,10 +2703,10 @@ switchbot-api==2.2.1
synology-srm==0.2.0
# homeassistant.components.system_bridge
-systembridgeconnector==4.1.0
+systembridgeconnector==4.1.5
# homeassistant.components.system_bridge
-systembridgemodels==4.1.0
+systembridgemodels==4.2.4
# homeassistant.components.tailscale
tailscale==0.6.1
@@ -2807,7 +2816,7 @@ twitchAPI==4.2.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==5.4.0
+uiprotect==6.0.2
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2836,7 +2845,7 @@ upcloud-api==2.5.1
url-normalize==1.4.3
# homeassistant.components.uvc
-uvcclient==0.11.0
+uvcclient==0.12.1
# homeassistant.components.roborock
vacuum-map-parser-roborock==0.1.2
@@ -2848,7 +2857,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.7.5
+velbus-aio==2024.7.6
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2896,9 +2905,6 @@ weatherflow4py==0.2.21
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
-# homeassistant.components.assist_pipeline
-webrtc-noise-gain==1.2.3
-
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8
@@ -2912,7 +2918,7 @@ wiffi==1.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
-wled==0.20.0
+wled==0.20.2
# homeassistant.components.wolflink
wolf-comm==0.0.9
@@ -2927,7 +2933,7 @@ xbox-webapi==2.0.11
xiaomi-ble==0.30.2
# homeassistant.components.knx
-xknx==2.12.2
+xknx==3.1.0
# homeassistant.components.knx
xknxproject==3.7.1
@@ -2950,7 +2956,7 @@ yalesmartalarmclient==0.3.9
yalexs-ble==2.4.3
# homeassistant.components.august
-yalexs==6.4.3
+yalexs==8.0.2
# homeassistant.components.yeelight
yeelight==0.7.14
@@ -2959,7 +2965,7 @@ yeelight==0.7.14
yeelightsunflower==0.0.10
# homeassistant.components.yolink
-yolink-api==0.4.4
+yolink-api==0.4.7
# homeassistant.components.youless
youless-api==2.1.2
@@ -2968,7 +2974,7 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp==2024.07.16
+yt-dlp==2024.08.06
# homeassistant.components.zamg
zamg==0.3.6
@@ -2983,7 +2989,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
-zha==0.0.24
+zha==0.0.31
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.12
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 33c1ebcdb09aa2..8cf132dd9a7f52 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -4,7 +4,7 @@
-r requirements_test.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.3
+AEMET-OpenData==0.5.4
# homeassistant.components.honeywell
AIOSomecomfort==0.0.25
@@ -164,10 +164,10 @@ aio-georss-gdacs==0.9
aioairq==0.3.2
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.6.1
+aioairzone-cloud==0.6.2
# homeassistant.components.airzone
-aioairzone==0.8.1
+aioairzone==0.8.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -186,7 +186,7 @@ aioaseko==0.2.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2024.6.4
+aioautomower==2024.8.0
# homeassistant.components.azure_devops
aioazuredevops==2.1.1
@@ -225,7 +225,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==24.6.2
+aioesphomeapi==25.1.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -240,7 +240,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
-aiohomekit==3.2.1
+aiohomekit==3.2.2
# homeassistant.components.hue
aiohue==4.7.2
@@ -317,7 +317,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
-aioqsw==0.4.0
+aioqsw==0.4.1
# homeassistant.components.rainforest_raven
aioraven==0.7.0
@@ -332,7 +332,7 @@ aioridwell==2024.01.0
aioruckus==0.34
# homeassistant.components.russound_rio
-aiorussound==2.2.0
+aiorussound==2.3.2
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -341,7 +341,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==11.1.0
+aioshelly==11.2.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -356,7 +356,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.switcher_kis
-aioswitcher==3.4.3
+aioswitcher==4.0.2
# homeassistant.components.syncthing
aiosyncthing==0.5.1
@@ -368,7 +368,7 @@ aiotankerkoenig==0.4.1
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==79
+aiounifi==80
# homeassistant.components.vlc_telnet
aiovlc==0.3.2
@@ -389,10 +389,10 @@ aiowebostv==0.4.2
aiowithings==3.0.2
# homeassistant.components.yandex_transport
-aioymaps==1.2.4
+aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.7.1
+airgradient==0.8.0
# homeassistant.components.airly
airly==1.1.0
@@ -424,6 +424,9 @@ anova-wifi==0.17.0
# homeassistant.components.anthemav
anthemav==1.4.1
+# homeassistant.components.anthropic
+anthropic==0.31.2
+
# homeassistant.components.weatherkit
apple_weatherkit==1.1.2
@@ -442,9 +445,6 @@ aranet4==2.3.4
# homeassistant.components.arcam_fmj
arcam-fmj==1.5.2
-# homeassistant.components.asterisk_mbox
-asterisk_mbox==0.5.0
-
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
# homeassistant.components.samsungtv
@@ -524,7 +524,7 @@ bluetooth-auto-recovery==1.4.2
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.19.3
+bluetooth-data-tools==1.19.4
# homeassistant.components.bond
bond-async==0.2.1
@@ -562,6 +562,9 @@ cached_ipaddress==0.3.0
# homeassistant.components.caldav
caldav==1.3.9
+# homeassistant.components.coinbase
+coinbase-advanced-py==1.2.2
+
# homeassistant.components.coinbase
coinbase==2.1.0
@@ -599,7 +602,7 @@ dbus-fast==2.22.1
debugpy==1.8.1
# homeassistant.components.ecovacs
-deebot-client==8.2.0
+deebot-client==8.3.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -625,7 +628,7 @@ devolo-home-control-api==0.18.3
devolo-plc-api==1.4.1
# homeassistant.components.chacon_dio
-dio-chacon-wifi-api==1.1.0
+dio-chacon-wifi-api==1.2.0
# homeassistant.components.directv
directv==0.4.0
@@ -660,6 +663,9 @@ easyenergy==2.1.2
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
+# homeassistant.components.elevenlabs
+elevenlabs==1.6.1
+
# homeassistant.components.elgato
elgato==5.1.2
@@ -711,6 +717,9 @@ eternalegypt==0.0.16
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
+# homeassistant.components.evohome
+evohome-async==0.4.20
+
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -827,7 +836,7 @@ google-cloud-pubsub==2.13.11
google-generativeai==0.6.0
# homeassistant.components.nest
-google-nest-sdm==4.0.5
+google-nest-sdm==4.0.6
# homeassistant.components.google_travel_time
googlemaps==2.5.1
@@ -845,7 +854,7 @@ govee-local-api==1.5.1
gps3==0.33.3
# homeassistant.components.gree
-greeclimate==1.4.6
+greeclimate==2.1.0
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -913,16 +922,16 @@ hole==0.8.0
holidays==0.53
# homeassistant.components.frontend
-home-assistant-frontend==20240719.0
+home-assistant-frontend==20240809.0
# homeassistant.components.conversation
-home-assistant-intents==2024.7.29
+home-assistant-intents==2024.8.7
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
-homematicip==1.1.1
+homematicip==1.1.2
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -1009,7 +1018,7 @@ kegtron-ble==0.4.0
knocki==0.3.1
# homeassistant.components.knx
-knx-frontend==2024.7.25.204106
+knx-frontend==2024.8.9.225351
# homeassistant.components.konnected
konnected==1.2.0
@@ -1018,7 +1027,7 @@ konnected==1.2.0
krakenex==2.1.0
# homeassistant.components.lacrosse_view
-lacrosse-view==1.0.1
+lacrosse-view==1.0.2
# homeassistant.components.laundrify
laundrify-aio==1.2.2
@@ -1047,9 +1056,6 @@ linear-garage-door==0.2.9
# homeassistant.components.lamarzocco
lmcloud==1.1.13
-# homeassistant.components.logi_circle
-logi-circle==0.2.3
-
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1066,7 +1072,7 @@ lupupy==0.3.2
lxml==5.1.0
# homeassistant.components.matrix
-matrix-nio==0.24.0
+matrix-nio==0.25.0
# homeassistant.components.maxcube
maxcube-api==0.4.3
@@ -1090,7 +1096,7 @@ melnor-bluetooth==0.0.25
meteofrance-api==1.3.0
# homeassistant.components.mfi
-mficlient==0.3.0
+mficlient==0.5.0
# homeassistant.components.xiaomi_miio
micloud==0.5
@@ -1114,13 +1120,13 @@ moat-ble==0.1.1
moehlenhoff-alpha2==1.3.1
# homeassistant.components.monzo
-monzopy==1.3.0
+monzopy==1.3.2
# homeassistant.components.mopeka
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
-motionblinds==0.6.23
+motionblinds==0.6.24
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.0
@@ -1173,6 +1179,9 @@ nextdns==3.1.0
# homeassistant.components.nibe_heatpump
nibe==2.11.0
+# homeassistant.components.nice_go
+nice-go==0.1.6
+
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@@ -1205,7 +1214,7 @@ objgraph==3.5.0
odp-amsterdam==6.0.2
# homeassistant.components.ollama
-ollama==0.3.0
+ollama==0.3.1
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -1232,7 +1241,7 @@ openerz-api==0.3.0
openhomedevice==2.2.0
# homeassistant.components.enigma2
-openwebifpy==4.2.5
+openwebifpy==4.2.7
# homeassistant.components.opower
opower==0.6.0
@@ -1339,7 +1348,7 @@ py-madvr2==1.6.29
py-melissa-climate==2.1.4
# homeassistant.components.nextbus
-py-nextbusnext==2.0.3
+py-nextbusnext==2.0.4
# homeassistant.components.nightscout
py-nightscout==1.2.2
@@ -1348,7 +1357,7 @@ py-nightscout==1.2.2
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.4.4
+py-synologydsm-api==2.5.2
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -1397,7 +1406,7 @@ pyatag==0.3.5.3
pyatmo==8.0.3
# homeassistant.components.apple_tv
-pyatv==0.14.3
+pyatv==0.15.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1424,13 +1433,13 @@ pycfdns==3.0.0
pycomfoconnect==0.5.1
# homeassistant.components.coolmaster
-pycoolmasternet-async==0.2.0
+pycoolmasternet-async==0.2.2
# homeassistant.components.microsoft
pycsspeechtts==1.0.8
# homeassistant.components.daikin
-pydaikin==2.13.1
+pydaikin==2.13.4
# homeassistant.components.deconz
pydeconz==116
@@ -1442,7 +1451,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.1
# homeassistant.components.hydrawise
-pydrawise==2024.6.4
+pydrawise==2024.8.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1466,7 +1475,7 @@ pyegps==0.2.5
pyemoncms==0.0.7
# homeassistant.components.enphase_envoy
-pyenphase==1.20.6
+pyenphase==1.22.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1487,7 +1496,7 @@ pyfido==2.1.2
pyfireservicerota==0.0.43
# homeassistant.components.flic
-pyflic==2.0.3
+pyflic==2.0.4
# homeassistant.components.forked_daapd
pyforked-daapd==0.1.14
@@ -1517,7 +1526,7 @@ pyhiveapi==0.5.16
pyhomematic==0.1.77
# homeassistant.components.homeworks
-pyhomeworks==1.1.0
+pyhomeworks==1.1.1
# homeassistant.components.ialarm
pyialarm==2.2.0
@@ -1544,7 +1553,7 @@ pyiss==1.0.1
pyisy==3.1.14
# homeassistant.components.jvc_projector
-pyjvcprojector==1.0.11
+pyjvcprojector==1.0.12
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1
@@ -1586,7 +1595,7 @@ pylitejet==0.6.2
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.20.0
+pylutron-caseta==0.21.1
# homeassistant.components.lutron
pylutron==0.2.15
@@ -1603,6 +1612,9 @@ pymelcloud==2.5.9
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.1.0
+# homeassistant.components.assist_pipeline
+pymicro-vad==1.0.1
+
# homeassistant.components.mochad
pymochad==0.2.0
@@ -1646,7 +1658,7 @@ pyoctoprintapi==0.1.12
pyopenuv==2023.02.0
# homeassistant.components.openweathermap
-pyopenweathermap==0.0.9
+pyopenweathermap==0.1.1
# homeassistant.components.opnsense
pyopnsense==0.4.0
@@ -1669,7 +1681,7 @@ pyoverkiz==1.13.14
pyownet==0.10.0.post1
# homeassistant.components.lcn
-pypck==0.7.17
+pypck==0.7.21
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -1714,7 +1726,7 @@ pyrympro==0.0.8
pysabnzbd==1.1.1
# homeassistant.components.schlage
-pyschlage==2024.6.0
+pyschlage==2024.8.0
# homeassistant.components.sensibo
pysensibo==1.0.36
@@ -1783,7 +1795,7 @@ python-MotionMount==2.0.0
python-awair==0.2.4
# homeassistant.components.bsblan
-python-bsblan==0.5.18
+python-bsblan==0.6.2
# homeassistant.components.ecobee
python-ecobee-api==0.2.18
@@ -1798,7 +1810,7 @@ python-fullykiosk==0.0.14
python-homeassistant-analytics==0.7.0
# homeassistant.components.homewizard
-python-homewizard-energy==v6.1.1
+python-homewizard-energy==v6.2.0
# homeassistant.components.izone
python-izone==1.2.9
@@ -1807,10 +1819,10 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.0.5
+python-kasa[speedups]==0.7.1
# homeassistant.components.linkplay
-python-linkplay==0.0.5
+python-linkplay==0.0.6
# homeassistant.components.matter
python-matter-server==6.3.0
@@ -1878,7 +1890,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.0.0
# homeassistant.components.v2c
-pytrydan==0.7.0
+pytrydan==0.8.0
# homeassistant.components.usb
pyudev==0.24.1
@@ -1959,7 +1971,7 @@ renault-api==0.2.5
renson-endura-delta==1.7.1
# homeassistant.components.reolink
-reolink-aio==0.9.6
+reolink-aio==0.9.7
# homeassistant.components.rflink
rflink==0.0.66
@@ -2032,7 +2044,7 @@ sfrbox-api==0.0.8
sharkiq==1.0.2
# homeassistant.components.simplefin
-simplefin4py==0.0.16
+simplefin4py==0.0.18
# homeassistant.components.sighthound
simplehound==0.3
@@ -2059,7 +2071,7 @@ snapcast==2.3.6
soco==0.30.4
# homeassistant.components.solarlog
-solarlog_cli==0.1.5
+solarlog_cli==0.1.6
# homeassistant.components.solax
solax==3.1.1
@@ -2128,10 +2140,10 @@ surepy==0.9.0
switchbot-api==2.2.1
# homeassistant.components.system_bridge
-systembridgeconnector==4.1.0
+systembridgeconnector==4.1.5
# homeassistant.components.system_bridge
-systembridgemodels==4.1.0
+systembridgemodels==4.2.4
# homeassistant.components.tailscale
tailscale==0.6.1
@@ -2208,7 +2220,7 @@ twitchAPI==4.2.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==5.4.0
+uiprotect==6.0.2
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2231,7 +2243,7 @@ upcloud-api==2.5.1
url-normalize==1.4.3
# homeassistant.components.uvc
-uvcclient==0.11.0
+uvcclient==0.12.1
# homeassistant.components.roborock
vacuum-map-parser-roborock==0.1.2
@@ -2243,7 +2255,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.7.5
+velbus-aio==2024.7.6
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2282,9 +2294,6 @@ weatherflow4py==0.2.21
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
-# homeassistant.components.assist_pipeline
-webrtc-noise-gain==1.2.3
-
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8
@@ -2295,7 +2304,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wled
-wled==0.20.0
+wled==0.20.2
# homeassistant.components.wolflink
wolf-comm==0.0.9
@@ -2310,7 +2319,7 @@ xbox-webapi==2.0.11
xiaomi-ble==0.30.2
# homeassistant.components.knx
-xknx==2.12.2
+xknx==3.1.0
# homeassistant.components.knx
xknxproject==3.7.1
@@ -2330,13 +2339,13 @@ yalesmartalarmclient==0.3.9
yalexs-ble==2.4.3
# homeassistant.components.august
-yalexs==6.4.3
+yalexs==8.0.2
# homeassistant.components.yeelight
yeelight==0.7.14
# homeassistant.components.yolink
-yolink-api==0.4.4
+yolink-api==0.4.7
# homeassistant.components.youless
youless-api==2.1.2
@@ -2345,7 +2354,7 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp==2024.07.16
+yt-dlp==2024.08.06
# homeassistant.components.zamg
zamg==0.3.6
@@ -2357,7 +2366,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
-zha==0.0.24
+zha==0.0.31
# homeassistant.components.zwave_js
zwave-js-server-python==0.57.0
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index d57a005bb5d9c5..ba54a19da3ee0b 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.3.0
-ruff==0.5.5
+ruff==0.5.7
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index f887f8113a7fbf..522a626754da0f 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -101,11 +101,6 @@
grpcio-status==1.59.0
grpcio-reflection==1.59.0
-# libcst >=0.4.0 requires a newer Rust than we currently have available,
-# thus our wheels builds fail. This pins it to the last working version,
-# which at this point satisfies our needs.
-libcst==0.3.23
-
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -120,11 +115,6 @@
typing==1000000000.0.0
uuid==1000000000.0.0
-# regex causes segfault with version 2021.8.27
-# https://bitbucket.org/mrabarnett/mrab-regex/issues/421/2021827-results-in-fatal-python-error
-# This is fixed in 2021.8.28
-regex==2021.8.28
-
# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
@@ -174,7 +164,7 @@
# protobuf must be in package constraints for the wheel
# builder to build binary wheels
-protobuf==4.25.1
+protobuf==4.25.4
# faust-cchardet: Ensure we have a version we can build wheels
# 2.1.18 is the first version that works with our wheel builder
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index c39c070eba22bd..c5efd05948fc44 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -41,6 +41,7 @@
"local_todo",
"nmap_tracker",
"rpi_power",
+ "swiss_public_transport",
"waze_travel_time",
"zodiac",
}
diff --git a/script/licenses.py b/script/licenses.py
index f2298e473a2aaf..0663821ed2cea6 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -116,6 +116,7 @@ def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
"Unlicense",
"Apache-2",
"GPLv2",
+ "Python-2.0.1",
}
EXCEPTIONS = {
@@ -124,7 +125,7 @@ def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
"PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201
"aiocomelit", # https://github.com/chemelli74/aiocomelit/pull/138
"aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180
- "aiohappyeyeballs", # PSF-2.0 license
+ "aiohappyeyeballs", # Python-2.0.1
"aioopenexchangerates", # https://github.com/MartinHjelmare/aioopenexchangerates/pull/94
"aiooui", # https://github.com/Bluetooth-Devices/aiooui/pull/8
"aioruuvigateway", # https://github.com/akx/aioruuvigateway/pull/6
@@ -160,7 +161,6 @@ def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
"pyTibber", # https://github.com/Danielhiversen/pyTibber/pull/294
"pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5
"pyeconet", # https://github.com/w1ll1am23/pyeconet/pull/41
- "pylutron-caseta", # https://github.com/gurumitts/pylutron-caseta/pull/168
"pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6
"pyvera", # https://github.com/maximvelichko/pyvera/pull/164
"pyxeoma", # https://github.com/jeradM/pyxeoma/pull/11
@@ -172,7 +172,6 @@ def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
"tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5
"tellduslive", # https://github.com/molobrakos/tellduslive/pull/24
"tellsticknet", # https://github.com/molobrakos/tellsticknet/pull/33
- "webrtc_noise_gain", # https://github.com/rhasspy/webrtc-noise-gain/pull/24
"vincenty", # Public domain
"zeversolar", # https://github.com/kvanzuijlen/zeversolar/pull/46
}
@@ -181,16 +180,6 @@ def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
"aiocache": AwesomeVersion(
"0.12.2"
), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved?
- "asterisk_mbox": AwesomeVersion(
- "0.5.0"
- ), # No license, integration is deprecated and scheduled for removal in 2024.9.0
- "mficlient": AwesomeVersion(
- "0.3.0"
- ), # No license https://github.com/kk7ds/mficlient/issues/4
- "pyflic": AwesomeVersion("2.0.3"), # No OSI approved license CC0-1.0 Universal)
- "uvcclient": AwesomeVersion(
- "0.11.0"
- ), # No License https://github.com/kk7ds/uvcclient/issues/7
}
@@ -210,7 +199,7 @@ def main() -> int:
if previous_unapproved_version < package.version:
if approved:
print(
- "Approved license detected for"
+ "Approved license detected for "
f"{package.name}@{package.version}: {package.license}"
)
print("Please remove the package from the TODO list.")
@@ -224,14 +213,14 @@ def main() -> int:
exit_code = 1
elif not approved and package.name not in EXCEPTIONS:
print(
- "We could not detect an OSI-approved license for"
+ "We could not detect an OSI-approved license for "
f"{package.name}@{package.version}: {package.license}"
)
print()
exit_code = 1
elif approved and package.name in EXCEPTIONS:
print(
- "Approved license detected for"
+ "Approved license detected for "
f"{package.name}@{package.version}: {package.license}"
)
print(f"Please remove the package from the EXCEPTIONS list: {package.name}")
diff --git a/script/lint_and_test.py b/script/lint_and_test.py
index e23870364b6307..ff3db8aa1ede7a 100755
--- a/script/lint_and_test.py
+++ b/script/lint_and_test.py
@@ -20,7 +20,7 @@
RE_ASCII = re.compile(r"\033\[[^m]*m")
-Error = namedtuple("Error", ["file", "line", "col", "msg", "skip"])
+Error = namedtuple("Error", ["file", "line", "col", "msg", "skip"]) # noqa: PYI024
PASS = "green"
FAIL = "bold_red"
diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py
index 809902fa0dd470..8e7854835d8961 100644
--- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py
@@ -59,7 +59,7 @@ def get_suggested(schema, key):
return None
return k.description["suggested_value"]
# Wanted key absent from schema
- raise Exception
+ raise KeyError(f"Key `{key}` is missing from schema")
@pytest.mark.parametrize("platform", ["sensor"])
diff --git a/tests/common.py b/tests/common.py
index 64e11ee7b515e5..d36df509142644 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -93,6 +93,7 @@
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.async_ import (
_SHUTDOWN_RUN_CALLBACK_THREADSAFE,
+ get_scheduled_timer_handles,
run_callback_threadsafe,
)
import homeassistant.util.dt as dt_util
@@ -531,7 +532,7 @@ def _async_fire_time_changed(
hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool
) -> None:
timestamp = dt_util.utc_to_timestamp(utc_datetime)
- for task in list(hass.loop._scheduled):
+ for task in list(get_scheduled_timer_handles(hass.loop)):
if not isinstance(task, asyncio.TimerHandle):
continue
if task.cancelled():
diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr
index 49bf40088843dd..cbe1891d2169bf 100644
--- a/tests/components/accuweather/snapshots/test_weather.ambr
+++ b/tests/components/accuweather/snapshots/test_weather.ambr
@@ -1,85 +1,4 @@
# serializer version: 1
-# name: test_forecast_service[get_forecast]
- dict({
- 'forecast': list([
- dict({
- 'apparent_temperature': 29.8,
- 'cloud_coverage': 58,
- 'condition': 'lightning-rainy',
- 'datetime': '2020-07-26T05:00:00+00:00',
- 'humidity': 60,
- 'precipitation': 2.5,
- 'precipitation_probability': 60,
- 'temperature': 29.5,
- 'templow': 15.4,
- 'uv_index': 5,
- 'wind_bearing': 166,
- 'wind_gust_speed': 29.6,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 28.9,
- 'cloud_coverage': 52,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-27T05:00:00+00:00',
- 'humidity': 58,
- 'precipitation': 0.0,
- 'precipitation_probability': 25,
- 'temperature': 26.2,
- 'templow': 15.9,
- 'uv_index': 7,
- 'wind_bearing': 297,
- 'wind_gust_speed': 14.8,
- 'wind_speed': 9.3,
- }),
- dict({
- 'apparent_temperature': 31.6,
- 'cloud_coverage': 65,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-28T05:00:00+00:00',
- 'humidity': 52,
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 31.7,
- 'templow': 16.8,
- 'uv_index': 7,
- 'wind_bearing': 198,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 16.7,
- }),
- dict({
- 'apparent_temperature': 26.5,
- 'cloud_coverage': 45,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-29T05:00:00+00:00',
- 'humidity': 65,
- 'precipitation': 0.0,
- 'precipitation_probability': 9,
- 'temperature': 24.0,
- 'templow': 11.7,
- 'uv_index': 6,
- 'wind_bearing': 293,
- 'wind_gust_speed': 24.1,
- 'wind_speed': 13.0,
- }),
- dict({
- 'apparent_temperature': 22.2,
- 'cloud_coverage': 50,
- 'condition': 'partlycloudy',
- 'datetime': '2020-07-30T05:00:00+00:00',
- 'humidity': 55,
- 'precipitation': 0.0,
- 'precipitation_probability': 1,
- 'temperature': 21.4,
- 'templow': 12.2,
- 'uv_index': 7,
- 'wind_bearing': 280,
- 'wind_gust_speed': 27.8,
- 'wind_speed': 18.5,
- }),
- ]),
- })
-# ---
# name: test_forecast_service[get_forecasts]
dict({
'weather.home': dict({
diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr
index f19f95a6e80272..58c854dcda935c 100644
--- a/tests/components/aemet/snapshots/test_weather.ambr
+++ b/tests/components/aemet/snapshots/test_weather.ambr
@@ -1,494 +1,4 @@
# serializer version: 1
-# name: test_forecast_service[get_forecast]
- dict({
- 'forecast': list([
- dict({
- 'condition': 'snowy',
- 'datetime': '2021-01-08T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': 2.0,
- 'templow': -1.0,
- 'wind_bearing': 90.0,
- 'wind_speed': 0.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-09T23:00:00+00:00',
- 'precipitation_probability': 30,
- 'temperature': 4.0,
- 'templow': -4.0,
- 'wind_bearing': 45.0,
- 'wind_speed': 20.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-10T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': 3.0,
- 'templow': -7.0,
- 'wind_bearing': 0.0,
- 'wind_speed': 5.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-11T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': -1.0,
- 'templow': -13.0,
- 'wind_bearing': None,
- }),
- dict({
- 'condition': 'sunny',
- 'datetime': '2021-01-12T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': 6.0,
- 'templow': -11.0,
- 'wind_bearing': None,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-13T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': 6.0,
- 'templow': -7.0,
- 'wind_bearing': None,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-14T23:00:00+00:00',
- 'precipitation_probability': 0,
- 'temperature': 5.0,
- 'templow': -4.0,
- 'wind_bearing': None,
- }),
- ]),
- })
-# ---
-# name: test_forecast_service[get_forecast].1
- dict({
- 'forecast': list([
- dict({
- 'condition': 'snowy',
- 'datetime': '2021-01-09T12:00:00+00:00',
- 'precipitation': 2.7,
- 'precipitation_probability': 100,
- 'temperature': 0.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 22.0,
- 'wind_speed': 15.0,
- }),
- dict({
- 'condition': 'rainy',
- 'datetime': '2021-01-09T13:00:00+00:00',
- 'precipitation': 0.6,
- 'precipitation_probability': 100,
- 'temperature': 0.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 24.0,
- 'wind_speed': 14.0,
- }),
- dict({
- 'condition': 'rainy',
- 'datetime': '2021-01-09T14:00:00+00:00',
- 'precipitation': 0.8,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 20.0,
- 'wind_speed': 10.0,
- }),
- dict({
- 'condition': 'snowy',
- 'datetime': '2021-01-09T15:00:00+00:00',
- 'precipitation': 1.4,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 14.0,
- 'wind_speed': 8.0,
- }),
- dict({
- 'condition': 'snowy',
- 'datetime': '2021-01-09T16:00:00+00:00',
- 'precipitation': 1.2,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 13.0,
- 'wind_speed': 9.0,
- }),
- dict({
- 'condition': 'snowy',
- 'datetime': '2021-01-09T17:00:00+00:00',
- 'precipitation': 0.4,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 90.0,
- 'wind_gust_speed': 13.0,
- 'wind_speed': 7.0,
- }),
- dict({
- 'condition': 'rainy',
- 'datetime': '2021-01-09T18:00:00+00:00',
- 'precipitation': 0.3,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 12.0,
- 'wind_speed': 8.0,
- }),
- dict({
- 'condition': 'rainy',
- 'datetime': '2021-01-09T19:00:00+00:00',
- 'precipitation': 0.1,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 135.0,
- 'wind_gust_speed': 12.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-09T20:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 90.0,
- 'wind_gust_speed': 8.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-09T21:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 9.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-09T22:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 100,
- 'temperature': 1.0,
- 'wind_bearing': 90.0,
- 'wind_gust_speed': 11.0,
- 'wind_speed': 8.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-09T23:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 12.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'fog',
- 'datetime': '2021-01-10T00:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 0.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 10.0,
- 'wind_speed': 5.0,
- }),
- dict({
- 'condition': 'fog',
- 'datetime': '2021-01-10T01:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 0.0,
- 'wind_bearing': 0.0,
- 'wind_gust_speed': 11.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'fog',
- 'datetime': '2021-01-10T02:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': 0.0,
- 'wind_bearing': 0.0,
- 'wind_gust_speed': 9.0,
- 'wind_speed': 6.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T03:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': -1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 12.0,
- 'wind_speed': 8.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T04:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 10,
- 'temperature': -1.0,
- 'wind_bearing': 0.0,
- 'wind_gust_speed': 11.0,
- 'wind_speed': 5.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T05:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': -1.0,
- 'wind_bearing': 0.0,
- 'wind_gust_speed': 13.0,
- 'wind_speed': 9.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T06:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': -2.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 18.0,
- 'wind_speed': 13.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T07:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': -1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 25.0,
- 'wind_speed': 17.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T08:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': -1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 31.0,
- 'wind_speed': 21.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-10T09:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': 0.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 32.0,
- 'wind_speed': 21.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-10T10:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 15,
- 'temperature': 2.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 30.0,
- 'wind_speed': 21.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T11:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 3.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 32.0,
- 'wind_speed': 22.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T12:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 3.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 32.0,
- 'wind_speed': 20.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T13:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 3.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 30.0,
- 'wind_speed': 19.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T14:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 4.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 28.0,
- 'wind_speed': 17.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T15:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 3.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 25.0,
- 'wind_speed': 16.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T16:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 5,
- 'temperature': 2.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 24.0,
- 'wind_speed': 16.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-10T17:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 24.0,
- 'wind_speed': 17.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-10T18:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 25.0,
- 'wind_speed': 17.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T19:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 25.0,
- 'wind_speed': 16.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T20:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 24.0,
- 'wind_speed': 17.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T21:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 0.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 27.0,
- 'wind_speed': 19.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T22:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': 0.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 30.0,
- 'wind_speed': 21.0,
- }),
- dict({
- 'condition': 'cloudy',
- 'datetime': '2021-01-10T23:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 30.0,
- 'wind_speed': 19.0,
- }),
- dict({
- 'condition': 'partlycloudy',
- 'datetime': '2021-01-11T00:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -1.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 27.0,
- 'wind_speed': 16.0,
- }),
- dict({
- 'condition': 'clear-night',
- 'datetime': '2021-01-11T01:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -2.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 22.0,
- 'wind_speed': 12.0,
- }),
- dict({
- 'condition': 'clear-night',
- 'datetime': '2021-01-11T02:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -2.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 17.0,
- 'wind_speed': 10.0,
- }),
- dict({
- 'condition': 'clear-night',
- 'datetime': '2021-01-11T03:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -3.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 15.0,
- 'wind_speed': 11.0,
- }),
- dict({
- 'condition': 'clear-night',
- 'datetime': '2021-01-11T04:00:00+00:00',
- 'precipitation': 0.0,
- 'precipitation_probability': 0,
- 'temperature': -4.0,
- 'wind_bearing': 45.0,
- 'wind_gust_speed': 15.0,
- 'wind_speed': 10.0,
- }),
- dict({
- 'condition': 'clear-night',
- 'datetime': '2021-01-11T05:00:00+00:00',
- 'precipitation_probability': None,
- 'temperature': -4.0,
- 'wind_bearing': 0.0,
- 'wind_gust_speed': 15.0,
- 'wind_speed': 10.0,
- }),
- ]),
- })
-# ---
# name: test_forecast_service[get_forecasts]
dict({
'weather.aemet': dict({
diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py
index a6ee85ecbdd0e6..1899e12c8aeaf0 100644
--- a/tests/components/airgradient/conftest.py
+++ b/tests/components/airgradient/conftest.py
@@ -44,6 +44,7 @@ def mock_airgradient_client() -> Generator[AsyncMock]:
client.get_config.return_value = Config.from_json(
load_fixture("get_config_local.json", DOMAIN)
)
+ client.get_latest_firmware_version.return_value = "3.1.4"
yield client
diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr
new file mode 100644
index 00000000000000..c639a97d5dde11
--- /dev/null
+++ b/tests/components/airgradient/snapshots/test_update.ambr
@@ -0,0 +1,58 @@
+# serializer version: 1
+# name: test_all_entities[update.airgradient_firmware-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'update',
+ 'entity_category': ,
+ 'entity_id': 'update.airgradient_firmware',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Firmware',
+ 'platform': 'airgradient',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '84fce612f5b8-update',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[update.airgradient_firmware-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'auto_update': False,
+ 'device_class': 'firmware',
+ 'entity_picture': 'https://brands.home-assistant.io/_/airgradient/icon.png',
+ 'friendly_name': 'Airgradient Firmware',
+ 'in_progress': False,
+ 'installed_version': '3.1.1',
+ 'latest_version': '3.1.4',
+ 'release_summary': None,
+ 'release_url': None,
+ 'skipped_version': None,
+ 'supported_features': ,
+ 'title': None,
+ }),
+ 'context': ,
+ 'entity_id': 'update.airgradient_firmware',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/airgradient/test_update.py b/tests/components/airgradient/test_update.py
new file mode 100644
index 00000000000000..020a9a82a714ed
--- /dev/null
+++ b/tests/components/airgradient/test_update.py
@@ -0,0 +1,69 @@
+"""Tests for the AirGradient update platform."""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, patch
+
+from freezegun.api import FrozenDateTimeFactory
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import STATE_OFF, STATE_ON, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+
+async def test_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test all entities."""
+ with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.UPDATE]):
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_update_mechanism(
+ hass: HomeAssistant,
+ mock_airgradient_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test update entity."""
+ await setup_integration(hass, mock_config_entry)
+
+ state = hass.states.get("update.airgradient_firmware")
+ assert state.state == STATE_ON
+ assert state.attributes["installed_version"] == "3.1.1"
+ assert state.attributes["latest_version"] == "3.1.4"
+ mock_airgradient_client.get_latest_firmware_version.assert_called_once()
+ mock_airgradient_client.get_latest_firmware_version.reset_mock()
+
+ mock_airgradient_client.get_current_measures.return_value.firmware_version = "3.1.4"
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("update.airgradient_firmware")
+ assert state.state == STATE_OFF
+ assert state.attributes["installed_version"] == "3.1.4"
+ assert state.attributes["latest_version"] == "3.1.4"
+
+ mock_airgradient_client.get_latest_firmware_version.return_value = "3.1.5"
+
+ freezer.tick(timedelta(minutes=59))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ mock_airgradient_client.get_latest_firmware_version.assert_called_once()
+ state = hass.states.get("update.airgradient_firmware")
+ assert state.state == STATE_ON
+ assert state.attributes["installed_version"] == "3.1.4"
+ assert state.attributes["latest_version"] == "3.1.5"
diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py
index 9fdcc1c89c19f8..43e7d77ce71bc1 100644
--- a/tests/components/alexa/test_common.py
+++ b/tests/components/alexa/test_common.py
@@ -7,7 +7,7 @@
from homeassistant.components.alexa import config, smart_home
from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE
-from homeassistant.core import Context, callback
+from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import entityfilter
from tests.common import async_mock_service
@@ -28,7 +28,7 @@ class MockConfig(smart_home.AlexaConfig):
"camera.test": {"display_categories": "CAMERA"},
}
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Mock Alexa config."""
super().__init__(
hass,
@@ -213,7 +213,7 @@ async def reported_properties(hass, endpoint, return_full_response=False):
class ReportedProperties:
"""Class to help assert reported properties."""
- def __init__(self, properties):
+ def __init__(self, properties) -> None:
"""Initialize class."""
self.properties = properties
diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py
index 19ae9828c225a9..e4c8efabc20f63 100644
--- a/tests/components/ambient_station/test_config_flow.py
+++ b/tests/components/ambient_station/test_config_flow.py
@@ -5,7 +5,7 @@
from aioambient.errors import AmbientError
import pytest
-from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN
+from homeassistant.components.ambient_station.const import CONF_APP_KEY, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py
index 1c32e1770e01e5..500b9e75cb3c7a 100644
--- a/tests/components/androidtv/patchers.py
+++ b/tests/components/androidtv/patchers.py
@@ -1,5 +1,6 @@
"""Define patches used for androidtv tests."""
+from typing import Any
from unittest.mock import patch
from androidtv.adb_manager.adb_manager_async import DeviceAsync
@@ -25,7 +26,7 @@
class AdbDeviceTcpAsyncFake:
"""A fake of the `adb_shell.adb_device_async.AdbDeviceTcpAsync` class."""
- def __init__(self, *args, **kwargs) -> None:
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize a fake `adb_shell.adb_device_async.AdbDeviceTcpAsync` instance."""
self.available = False
diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py
index e2b5207c59010f..b73fee9fb105f0 100644
--- a/tests/components/androidtv/test_config_flow.py
+++ b/tests/components/androidtv/test_config_flow.py
@@ -73,7 +73,7 @@
class MockConfigDevice:
"""Mock class to emulate Android device."""
- def __init__(self, eth_mac=ETH_MAC, wifi_mac=None):
+ def __init__(self, eth_mac=ETH_MAC, wifi_mac=None) -> None:
"""Initialize a fake device to test config flow."""
self.available = True
self.device_properties = {PROP_ETHMAC: eth_mac, PROP_WIFIMAC: wifi_mac}
diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py
new file mode 100644
index 00000000000000..99d7a5785a8878
--- /dev/null
+++ b/tests/components/anthropic/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Anthropic integration."""
diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py
new file mode 100644
index 00000000000000..ce6b98c480c6f5
--- /dev/null
+++ b/tests/components/anthropic/conftest.py
@@ -0,0 +1,57 @@
+"""Tests helpers."""
+
+from collections.abc import AsyncGenerator
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from homeassistant.const import CONF_LLM_HASS_API
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import llm
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
+ """Mock a config entry."""
+ entry = MockConfigEntry(
+ title="Claude",
+ domain="anthropic",
+ data={
+ "api_key": "bla",
+ },
+ )
+ entry.add_to_hass(hass)
+ return entry
+
+
+@pytest.fixture
+def mock_config_entry_with_assist(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> MockConfigEntry:
+ """Mock a config entry with assist."""
+ hass.config_entries.async_update_entry(
+ mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}
+ )
+ return mock_config_entry
+
+
+@pytest.fixture
+async def mock_init_component(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> AsyncGenerator[None]:
+ """Initialize integration."""
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock
+ ):
+ assert await async_setup_component(hass, "anthropic", {})
+ await hass.async_block_till_done()
+ yield
+
+
+@pytest.fixture(autouse=True)
+async def setup_ha(hass: HomeAssistant) -> None:
+ """Set up Home Assistant."""
+ assert await async_setup_component(hass, "homeassistant", {})
diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr
new file mode 100644
index 00000000000000..e4dd7cd00bb809
--- /dev/null
+++ b/tests/components/anthropic/snapshots/test_conversation.ambr
@@ -0,0 +1,34 @@
+# serializer version: 1
+# name: test_unknown_hass_api
+ dict({
+ 'conversation_id': None,
+ 'response': IntentResponse(
+ card=dict({
+ }),
+ error_code=,
+ failed_results=list([
+ ]),
+ intent=None,
+ intent_targets=list([
+ ]),
+ language='en',
+ matched_states=list([
+ ]),
+ reprompt=dict({
+ }),
+ response_type=,
+ speech=dict({
+ 'plain': dict({
+ 'extra_data': None,
+ 'speech': 'Error preparing LLM API: API non-existing not found',
+ }),
+ }),
+ speech_slots=dict({
+ }),
+ success_results=list([
+ ]),
+ unmatched_states=list([
+ ]),
+ ),
+ })
+# ---
diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py
new file mode 100644
index 00000000000000..df27352b7b2698
--- /dev/null
+++ b/tests/components/anthropic/test_config_flow.py
@@ -0,0 +1,239 @@
+"""Test the Anthropic config flow."""
+
+from unittest.mock import AsyncMock, patch
+
+from anthropic import (
+ APIConnectionError,
+ APIResponseValidationError,
+ APITimeoutError,
+ AuthenticationError,
+ BadRequestError,
+ InternalServerError,
+)
+from httpx import URL, Request, Response
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.anthropic.config_flow import RECOMMENDED_OPTIONS
+from homeassistant.components.anthropic.const import (
+ CONF_CHAT_MODEL,
+ CONF_MAX_TOKENS,
+ CONF_PROMPT,
+ CONF_RECOMMENDED,
+ CONF_TEMPERATURE,
+ DOMAIN,
+ RECOMMENDED_CHAT_MODEL,
+ RECOMMENDED_MAX_TOKENS,
+)
+from homeassistant.const import CONF_LLM_HASS_API
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from tests.common import MockConfigEntry
+
+
+async def test_form(hass: HomeAssistant) -> None:
+ """Test we get the form."""
+ # Pretend we already set up a config entry.
+ hass.config.components.add("anthropic")
+ MockConfigEntry(
+ domain=DOMAIN,
+ state=config_entries.ConfigEntryState.LOADED,
+ ).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] is None
+
+ with (
+ patch(
+ "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ ),
+ patch(
+ "homeassistant.components.anthropic.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "api_key": "bla",
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.CREATE_ENTRY
+ assert result2["data"] == {
+ "api_key": "bla",
+ }
+ assert result2["options"] == RECOMMENDED_OPTIONS
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_options(
+ hass: HomeAssistant, mock_config_entry, mock_init_component
+) -> None:
+ """Test the options form."""
+ options_flow = await hass.config_entries.options.async_init(
+ mock_config_entry.entry_id
+ )
+ options = await hass.config_entries.options.async_configure(
+ options_flow["flow_id"],
+ {
+ "prompt": "Speak like a pirate",
+ "max_tokens": 200,
+ },
+ )
+ await hass.async_block_till_done()
+ assert options["type"] is FlowResultType.CREATE_ENTRY
+ assert options["data"]["prompt"] == "Speak like a pirate"
+ assert options["data"]["max_tokens"] == 200
+ assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL
+
+
+@pytest.mark.parametrize(
+ ("side_effect", "error"),
+ [
+ (APIConnectionError(request=None), "cannot_connect"),
+ (APITimeoutError(request=None), "timeout_connect"),
+ (
+ BadRequestError(
+ message="Your credit balance is too low to access the Claude API. Please go to Plans & Billing to upgrade or purchase credits.",
+ response=Response(
+ status_code=400,
+ request=Request(method="POST", url=URL()),
+ ),
+ body={"type": "error", "error": {"type": "invalid_request_error"}},
+ ),
+ "invalid_request_error",
+ ),
+ (
+ AuthenticationError(
+ message="invalid x-api-key",
+ response=Response(
+ status_code=401,
+ request=Request(method="POST", url=URL()),
+ ),
+ body={"type": "error", "error": {"type": "authentication_error"}},
+ ),
+ "authentication_error",
+ ),
+ (
+ InternalServerError(
+ message=None,
+ response=Response(
+ status_code=500,
+ request=Request(method="POST", url=URL()),
+ ),
+ body=None,
+ ),
+ "unknown",
+ ),
+ (
+ APIResponseValidationError(
+ response=Response(
+ status_code=200,
+ request=Request(method="POST", url=URL()),
+ ),
+ body=None,
+ ),
+ "unknown",
+ ),
+ ],
+)
+async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None:
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=side_effect,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "api_key": "bla",
+ },
+ )
+
+ assert result2["type"] is FlowResultType.FORM
+ assert result2["errors"] == {"base": error}
+
+
+@pytest.mark.parametrize(
+ ("current_options", "new_options", "expected_options"),
+ [
+ (
+ {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: "none",
+ CONF_PROMPT: "bla",
+ },
+ {
+ CONF_RECOMMENDED: False,
+ CONF_PROMPT: "Speak like a pirate",
+ CONF_TEMPERATURE: 0.3,
+ },
+ {
+ CONF_RECOMMENDED: False,
+ CONF_PROMPT: "Speak like a pirate",
+ CONF_TEMPERATURE: 0.3,
+ CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
+ CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
+ },
+ ),
+ (
+ {
+ CONF_RECOMMENDED: False,
+ CONF_PROMPT: "Speak like a pirate",
+ CONF_TEMPERATURE: 0.3,
+ CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
+ CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
+ },
+ {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: "assist",
+ CONF_PROMPT: "",
+ },
+ {
+ CONF_RECOMMENDED: True,
+ CONF_LLM_HASS_API: "assist",
+ CONF_PROMPT: "",
+ },
+ ),
+ ],
+)
+async def test_options_switching(
+ hass: HomeAssistant,
+ mock_config_entry,
+ mock_init_component,
+ current_options,
+ new_options,
+ expected_options,
+) -> None:
+ """Test the options form."""
+ hass.config_entries.async_update_entry(mock_config_entry, options=current_options)
+ options_flow = await hass.config_entries.options.async_init(
+ mock_config_entry.entry_id
+ )
+ if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED):
+ options_flow = await hass.config_entries.options.async_configure(
+ options_flow["flow_id"],
+ {
+ **current_options,
+ CONF_RECOMMENDED: new_options[CONF_RECOMMENDED],
+ },
+ )
+ options = await hass.config_entries.options.async_configure(
+ options_flow["flow_id"],
+ new_options,
+ )
+ await hass.async_block_till_done()
+ assert options["type"] is FlowResultType.CREATE_ENTRY
+ assert options["data"] == expected_options
diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py
new file mode 100644
index 00000000000000..65ede87728160b
--- /dev/null
+++ b/tests/components/anthropic/test_conversation.py
@@ -0,0 +1,487 @@
+"""Tests for the Anthropic integration."""
+
+from unittest.mock import AsyncMock, Mock, patch
+
+from anthropic import RateLimitError
+from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
+from freezegun import freeze_time
+from httpx import URL, Request, Response
+from syrupy.assertion import SnapshotAssertion
+import voluptuous as vol
+
+from homeassistant.components import conversation
+from homeassistant.components.conversation import trace
+from homeassistant.const import CONF_LLM_HASS_API
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import intent, llm
+from homeassistant.setup import async_setup_component
+from homeassistant.util import ulid
+
+from tests.common import MockConfigEntry
+
+
+async def test_entity(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test entity properties."""
+ state = hass.states.get("conversation.claude")
+ assert state
+ assert state.attributes["supported_features"] == 0
+
+ hass.config_entries.async_update_entry(
+ mock_config_entry,
+ options={
+ **mock_config_entry.options,
+ CONF_LLM_HASS_API: "assist",
+ },
+ )
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock
+ ):
+ await hass.config_entries.async_reload(mock_config_entry.entry_id)
+
+ state = hass.states.get("conversation.claude")
+ assert state
+ assert (
+ state.attributes["supported_features"]
+ == conversation.ConversationEntityFeature.CONTROL
+ )
+
+
+async def test_error_handling(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component
+) -> None:
+ """Test that the default prompt works."""
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=RateLimitError(
+ message=None,
+ response=Response(
+ status_code=429, request=Request(method="POST", url=URL())
+ ),
+ body=None,
+ ),
+ ):
+ result = await conversation.async_converse(
+ hass, "hello", None, Context(), agent_id="conversation.claude"
+ )
+
+ assert result.response.response_type == intent.IntentResponseType.ERROR, result
+ assert result.response.error_code == "unknown", result
+
+
+async def test_template_error(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test that template error handling works."""
+ hass.config_entries.async_update_entry(
+ mock_config_entry,
+ options={
+ "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.",
+ },
+ )
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock
+ ):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+ result = await conversation.async_converse(
+ hass, "hello", None, Context(), agent_id="conversation.claude"
+ )
+
+ assert result.response.response_type == intent.IntentResponseType.ERROR, result
+ assert result.response.error_code == "unknown", result
+
+
+async def test_template_variables(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Test that template variables work."""
+ context = Context(user_id="12345")
+ mock_user = Mock()
+ mock_user.id = "12345"
+ mock_user.name = "Test User"
+
+ hass.config_entries.async_update_entry(
+ mock_config_entry,
+ options={
+ "prompt": (
+ "The user name is {{ user_name }}. "
+ "The user id is {{ llm_context.context.user_id }}."
+ ),
+ },
+ )
+ with (
+ patch(
+ "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock
+ ) as mock_create,
+ patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user),
+ ):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+ result = await conversation.async_converse(
+ hass, "hello", None, context, agent_id="conversation.claude"
+ )
+
+ assert (
+ result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ ), result
+ assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"]
+ assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"]
+
+
+async def test_conversation_agent(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test Anthropic Agent."""
+ agent = conversation.agent_manager.async_get_agent(hass, "conversation.claude")
+ assert agent.supported_languages == "*"
+
+
+@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools")
+async def test_function_call(
+ mock_get_tools,
+ hass: HomeAssistant,
+ mock_config_entry_with_assist: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test function call from the assistant."""
+ agent_id = "conversation.claude"
+ context = Context()
+
+ mock_tool = AsyncMock()
+ mock_tool.name = "test_tool"
+ mock_tool.description = "Test function"
+ mock_tool.parameters = vol.Schema(
+ {vol.Optional("param1", description="Test parameters"): str}
+ )
+ mock_tool.async_call.return_value = "Test response"
+
+ mock_get_tools.return_value = [mock_tool]
+
+ def completion_result(*args, messages, **kwargs):
+ for message in messages:
+ for content in message["content"]:
+ if not isinstance(content, str) and content["type"] == "tool_use":
+ return Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[
+ TextBlock(
+ type="text",
+ text="I have successfully called the function",
+ )
+ ],
+ model="claude-3-5-sonnet-20240620",
+ role="assistant",
+ stop_reason="end_turn",
+ stop_sequence=None,
+ usage=Usage(input_tokens=8, output_tokens=12),
+ )
+
+ return Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[
+ TextBlock(type="text", text="Certainly, calling it now!"),
+ ToolUseBlock(
+ type="tool_use",
+ id="toolu_0123456789AbCdEfGhIjKlM",
+ name="test_tool",
+ input={"param1": "test_value"},
+ ),
+ ],
+ model="claude-3-5-sonnet-20240620",
+ role="assistant",
+ stop_reason="tool_use",
+ stop_sequence=None,
+ usage=Usage(input_tokens=8, output_tokens=12),
+ )
+
+ with (
+ patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=completion_result,
+ ) as mock_create,
+ freeze_time("2024-06-03 23:00:00"),
+ ):
+ result = await conversation.async_converse(
+ hass,
+ "Please call the test function",
+ None,
+ context,
+ agent_id=agent_id,
+ )
+
+ assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"]
+
+ assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert mock_create.mock_calls[1][2]["messages"][2] == {
+ "role": "user",
+ "content": [
+ {
+ "content": '"Test response"',
+ "tool_use_id": "toolu_0123456789AbCdEfGhIjKlM",
+ "type": "tool_result",
+ }
+ ],
+ }
+ mock_tool.async_call.assert_awaited_once_with(
+ hass,
+ llm.ToolInput(
+ tool_name="test_tool",
+ tool_args={"param1": "test_value"},
+ ),
+ llm.LLMContext(
+ platform="anthropic",
+ context=context,
+ user_prompt="Please call the test function",
+ language="en",
+ assistant="conversation",
+ device_id=None,
+ ),
+ )
+
+ # Test Conversation tracing
+ traces = trace.async_get_traces()
+ assert traces
+ last_trace = traces[-1].as_dict()
+ trace_events = last_trace.get("events", [])
+ assert [event["event_type"] for event in trace_events] == [
+ trace.ConversationTraceEventType.ASYNC_PROCESS,
+ trace.ConversationTraceEventType.AGENT_DETAIL,
+ trace.ConversationTraceEventType.TOOL_CALL,
+ ]
+ # AGENT_DETAIL event contains the raw prompt passed to the model
+ detail_event = trace_events[1]
+ assert "Answer in plain text" in detail_event["data"]["system"]
+ assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"]
+
+ # Call it again, make sure we have updated prompt
+ with (
+ patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=completion_result,
+ ) as mock_create,
+ freeze_time("2024-06-04 23:00:00"),
+ ):
+ result = await conversation.async_converse(
+ hass,
+ "Please call the test function",
+ None,
+ context,
+ agent_id=agent_id,
+ )
+
+ assert "Today's date is 2024-06-04." in mock_create.mock_calls[1][2]["system"]
+ # Test old assert message not updated
+ assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"]
+
+
+@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools")
+async def test_function_exception(
+ mock_get_tools,
+ hass: HomeAssistant,
+ mock_config_entry_with_assist: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test function call with exception."""
+ agent_id = "conversation.claude"
+ context = Context()
+
+ mock_tool = AsyncMock()
+ mock_tool.name = "test_tool"
+ mock_tool.description = "Test function"
+ mock_tool.parameters = vol.Schema(
+ {vol.Optional("param1", description="Test parameters"): str}
+ )
+ mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception")
+
+ mock_get_tools.return_value = [mock_tool]
+
+ def completion_result(*args, messages, **kwargs):
+ for message in messages:
+ for content in message["content"]:
+ if not isinstance(content, str) and content["type"] == "tool_use":
+ return Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[
+ TextBlock(
+ type="text",
+ text="There was an error calling the function",
+ )
+ ],
+ model="claude-3-5-sonnet-20240620",
+ role="assistant",
+ stop_reason="end_turn",
+ stop_sequence=None,
+ usage=Usage(input_tokens=8, output_tokens=12),
+ )
+
+ return Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[
+ TextBlock(type="text", text="Certainly, calling it now!"),
+ ToolUseBlock(
+ type="tool_use",
+ id="toolu_0123456789AbCdEfGhIjKlM",
+ name="test_tool",
+ input={"param1": "test_value"},
+ ),
+ ],
+ model="claude-3-5-sonnet-20240620",
+ role="assistant",
+ stop_reason="tool_use",
+ stop_sequence=None,
+ usage=Usage(input_tokens=8, output_tokens=12),
+ )
+
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=completion_result,
+ ) as mock_create:
+ result = await conversation.async_converse(
+ hass,
+ "Please call the test function",
+ None,
+ context,
+ agent_id=agent_id,
+ )
+
+ assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert mock_create.mock_calls[1][2]["messages"][2] == {
+ "role": "user",
+ "content": [
+ {
+ "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}',
+ "tool_use_id": "toolu_0123456789AbCdEfGhIjKlM",
+ "type": "tool_result",
+ }
+ ],
+ }
+ mock_tool.async_call.assert_awaited_once_with(
+ hass,
+ llm.ToolInput(
+ tool_name="test_tool",
+ tool_args={"param1": "test_value"},
+ ),
+ llm.LLMContext(
+ platform="anthropic",
+ context=context,
+ user_prompt="Please call the test function",
+ language="en",
+ assistant="conversation",
+ device_id=None,
+ ),
+ )
+
+
+async def test_assist_api_tools_conversion(
+ hass: HomeAssistant,
+ mock_config_entry_with_assist: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test that we are able to convert actual tools from Assist API."""
+ for component in (
+ "intent",
+ "todo",
+ "light",
+ "shopping_list",
+ "humidifier",
+ "climate",
+ "media_player",
+ "vacuum",
+ "cover",
+ "weather",
+ ):
+ assert await async_setup_component(hass, component, {})
+
+ agent_id = "conversation.claude"
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ return_value=Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[TextBlock(type="text", text="Hello, how can I help you?")],
+ model="claude-3-5-sonnet-20240620",
+ role="assistant",
+ stop_reason="end_turn",
+ stop_sequence=None,
+ usage=Usage(input_tokens=8, output_tokens=12),
+ ),
+ ) as mock_create:
+ await conversation.async_converse(
+ hass, "hello", None, Context(), agent_id=agent_id
+ )
+
+ tools = mock_create.mock_calls[0][2]["tools"]
+ assert tools
+
+
+async def test_unknown_hass_api(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ mock_init_component,
+) -> None:
+ """Test when we reference an API that no longer exists."""
+ hass.config_entries.async_update_entry(
+ mock_config_entry,
+ options={
+ **mock_config_entry.options,
+ CONF_LLM_HASS_API: "non-existing",
+ },
+ )
+
+ result = await conversation.async_converse(
+ hass, "hello", None, Context(), agent_id="conversation.claude"
+ )
+
+ assert result == snapshot
+
+
+@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock)
+async def test_conversation_id(
+ mock_create,
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_init_component,
+) -> None:
+ """Test conversation ID is honored."""
+ result = await conversation.async_converse(
+ hass, "hello", None, None, agent_id="conversation.claude"
+ )
+
+ conversation_id = result.conversation_id
+
+ result = await conversation.async_converse(
+ hass, "hello", conversation_id, None, agent_id="conversation.claude"
+ )
+
+ assert result.conversation_id == conversation_id
+
+ unknown_id = ulid.ulid()
+
+ result = await conversation.async_converse(
+ hass, "hello", unknown_id, None, agent_id="conversation.claude"
+ )
+
+ assert result.conversation_id != unknown_id
+
+ result = await conversation.async_converse(
+ hass, "hello", "koala", None, agent_id="conversation.claude"
+ )
+
+ assert result.conversation_id == "koala"
diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py
new file mode 100644
index 00000000000000..ee87bb708d0471
--- /dev/null
+++ b/tests/components/anthropic/test_init.py
@@ -0,0 +1,64 @@
+"""Tests for the Anthropic integration."""
+
+from unittest.mock import AsyncMock, patch
+
+from anthropic import (
+ APIConnectionError,
+ APITimeoutError,
+ AuthenticationError,
+ BadRequestError,
+)
+from httpx import URL, Request, Response
+import pytest
+
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+@pytest.mark.parametrize(
+ ("side_effect", "error"),
+ [
+ (APIConnectionError(request=None), "Connection error"),
+ (APITimeoutError(request=None), "Request timed out"),
+ (
+ BadRequestError(
+ message="Your credit balance is too low to access the Claude API. Please go to Plans & Billing to upgrade or purchase credits.",
+ response=Response(
+ status_code=400,
+ request=Request(method="POST", url=URL()),
+ ),
+ body={"type": "error", "error": {"type": "invalid_request_error"}},
+ ),
+ "anthropic integration not ready yet: Your credit balance is too low to access the Claude API",
+ ),
+ (
+ AuthenticationError(
+ message="invalid x-api-key",
+ response=Response(
+ status_code=401,
+ request=Request(method="POST", url=URL()),
+ ),
+ body={"type": "error", "error": {"type": "authentication_error"}},
+ ),
+ "Invalid API key",
+ ),
+ ],
+)
+async def test_init_error(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ caplog: pytest.LogCaptureFixture,
+ side_effect,
+ error,
+) -> None:
+ """Test initialization errors."""
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=side_effect,
+ ):
+ assert await async_setup_component(hass, "anthropic", {})
+ await hass.async_block_till_done()
+ assert error in caplog.text
diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py
index 0c7d174a5e8f74..0fe7f12ad27ebe 100644
--- a/tests/components/apcupsd/test_sensor.py
+++ b/tests/components/apcupsd/test_sensor.py
@@ -15,6 +15,7 @@
ATTR_UNIT_OF_MEASUREMENT,
PERCENTAGE,
STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
UnitOfElectricPotential,
UnitOfPower,
UnitOfTime,
@@ -25,7 +26,7 @@
from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
-from . import MOCK_STATUS, async_init_integration
+from . import MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration
from tests.common import async_fire_time_changed
@@ -237,3 +238,34 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None:
blocking=True,
)
assert mock_request_status.call_count == 1
+
+
+async def test_sensor_unknown(hass: HomeAssistant) -> None:
+ """Test if our integration can properly certain sensors as unknown when it becomes so."""
+ await async_init_integration(hass, status=MOCK_MINIMAL_STATUS)
+
+ assert hass.states.get("sensor.mode").state == MOCK_MINIMAL_STATUS["UPSMODE"]
+ # Last self test sensor should be added even if our status does not report it initially (it is
+ # a sensor that appears only after a periodical or manual self test is performed).
+ assert hass.states.get("sensor.last_self_test") is not None
+ assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN
+
+ # Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of
+ # the sensor should be properly updated with the corresponding value.
+ with patch("aioapcaccess.request_status") as mock_request_status:
+ mock_request_status.return_value = MOCK_MINIMAL_STATUS | {
+ "LASTSTEST": "1970-01-01 00:00:00 0000"
+ }
+ future = utcnow() + timedelta(minutes=2)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.last_self_test").state == "1970-01-01 00:00:00 0000"
+
+ # Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported.
+ with patch("aioapcaccess.request_status") as mock_request_status:
+ mock_request_status.return_value = MOCK_MINIMAL_STATUS
+ future = utcnow() + timedelta(minutes=2)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ # The state should become unknown again.
+ assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index c283aeb718e1db..abce262fd12308 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -3,6 +3,7 @@
import asyncio
from http import HTTPStatus
import json
+from typing import Any
from unittest.mock import patch
from aiohttp import ServerDisconnectedError, web
@@ -355,6 +356,67 @@ def listener(service_call):
assert state["attributes"] == {"data": 1}
+SERVICE_DICT = {"changed_states": [], "service_response": {"foo": "bar"}}
+RESP_REQUIRED = {
+ "message": (
+ "Service call requires responses but caller did not ask for "
+ "responses. Add ?return_response to query parameters."
+ )
+}
+RESP_UNSUPPORTED = {
+ "message": "Service does not support responses. Remove return_response from request."
+}
+
+
+@pytest.mark.parametrize(
+ (
+ "supports_response",
+ "requested_response",
+ "expected_number_of_service_calls",
+ "expected_status",
+ "expected_response",
+ ),
+ [
+ (ha.SupportsResponse.ONLY, True, 1, HTTPStatus.OK, SERVICE_DICT),
+ (ha.SupportsResponse.ONLY, False, 0, HTTPStatus.BAD_REQUEST, RESP_REQUIRED),
+ (ha.SupportsResponse.OPTIONAL, True, 1, HTTPStatus.OK, SERVICE_DICT),
+ (ha.SupportsResponse.OPTIONAL, False, 1, HTTPStatus.OK, []),
+ (ha.SupportsResponse.NONE, True, 0, HTTPStatus.BAD_REQUEST, RESP_UNSUPPORTED),
+ (ha.SupportsResponse.NONE, False, 1, HTTPStatus.OK, []),
+ ],
+)
+async def test_api_call_service_returns_response_requested_response(
+ hass: HomeAssistant,
+ mock_api_client: TestClient,
+ supports_response: ha.SupportsResponse,
+ requested_response: bool,
+ expected_number_of_service_calls: int,
+ expected_status: int,
+ expected_response: Any,
+) -> None:
+ """Test if the API allows us to call a service."""
+ test_value = []
+
+ @ha.callback
+ def listener(service_call):
+ """Record that our service got called."""
+ test_value.append(1)
+ return {"foo": "bar"}
+
+ hass.services.async_register(
+ "test_domain", "test_service", listener, supports_response=supports_response
+ )
+
+ resp = await mock_api_client.post(
+ "/api/services/test_domain/test_service"
+ + ("?return_response" if requested_response else "")
+ )
+ assert resp.status == expected_status
+ await hass.async_block_till_done()
+ assert len(test_value) == expected_number_of_service_calls
+ assert await resp.json() == expected_response
+
+
async def test_api_call_service_client_closed(
hass: HomeAssistant, mock_api_client: TestClient
) -> None:
diff --git a/tests/components/apple_tv/common.py b/tests/components/apple_tv/common.py
index ddb8c1348d9dde..8a81536c7922c4 100644
--- a/tests/components/apple_tv/common.py
+++ b/tests/components/apple_tv/common.py
@@ -1,5 +1,7 @@
"""Test code shared between test files."""
+from typing import Any
+
from pyatv import conf, const, interface
from pyatv.const import Protocol
@@ -7,7 +9,7 @@
class MockPairingHandler(interface.PairingHandler):
"""Mock for PairingHandler in pyatv."""
- def __init__(self, *args):
+ def __init__(self, *args: Any) -> None:
"""Initialize a new MockPairingHandler."""
super().__init__(*args)
self.pin_code = None
diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py
index e6fdf568bcc2f8..d90084fa7c9b10 100644
--- a/tests/components/application_credentials/test_init.py
+++ b/tests/components/application_credentials/test_init.py
@@ -124,7 +124,12 @@ def config_flow_handler(
class OAuthFixture:
"""Fixture to facilitate testing an OAuth flow."""
- def __init__(self, hass, hass_client, aioclient_mock):
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ ) -> None:
"""Initialize OAuthFixture."""
self.hass = hass
self.hass_client = hass_client
@@ -184,7 +189,7 @@ async def oauth_fixture(
class Client:
"""Test client with helper methods for application credentials websocket."""
- def __init__(self, client):
+ def __init__(self, client) -> None:
"""Initialize Client."""
self.client = client
self.id = 0
diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py
index c191c7ca2dc087..7e6140e8279eed 100644
--- a/tests/components/apsystems/conftest.py
+++ b/tests/components/apsystems/conftest.py
@@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
-from APsystemsEZ1 import ReturnDeviceInfo, ReturnOutputData, Status
+from APsystemsEZ1 import ReturnAlarmInfo, ReturnDeviceInfo, ReturnOutputData, Status
import pytest
from homeassistant.components.apsystems.const import DOMAIN
@@ -52,6 +52,12 @@ def mock_apsystems() -> Generator[MagicMock]:
e2=6.0,
te2=7.0,
)
+ mock_api.get_alarm_info.return_value = ReturnAlarmInfo(
+ og=Status.normal,
+ isce1=Status.alarm,
+ isce2=Status.normal,
+ oe=Status.alarm,
+ )
mock_api.get_device_power_status.return_value = Status.normal
yield mock_api
diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr
new file mode 100644
index 00000000000000..0875c88976b046
--- /dev/null
+++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr
@@ -0,0 +1,189 @@
+# serializer version: 1
+# name: test_all_entities[binary_sensor.mock_title_dc_1_short_circuit_error_status-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': ,
+ 'entity_id': 'binary_sensor.mock_title_dc_1_short_circuit_error_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'DC 1 short circuit error status',
+ 'platform': 'apsystems',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'dc_1_short_circuit_error_status',
+ 'unique_id': 'MY_SERIAL_NUMBER_dc_1_short_circuit_error_status',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_dc_1_short_circuit_error_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'problem',
+ 'friendly_name': 'Mock Title DC 1 short circuit error status',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.mock_title_dc_1_short_circuit_error_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_dc_2_short_circuit_error_status-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': ,
+ 'entity_id': 'binary_sensor.mock_title_dc_2_short_circuit_error_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'DC 2 short circuit error status',
+ 'platform': 'apsystems',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'dc_2_short_circuit_error_status',
+ 'unique_id': 'MY_SERIAL_NUMBER_dc_2_short_circuit_error_status',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_dc_2_short_circuit_error_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'problem',
+ 'friendly_name': 'Mock Title DC 2 short circuit error status',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.mock_title_dc_2_short_circuit_error_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_off_grid_status-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': ,
+ 'entity_id': 'binary_sensor.mock_title_off_grid_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Off grid status',
+ 'platform': 'apsystems',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'off_grid_status',
+ 'unique_id': 'MY_SERIAL_NUMBER_off_grid_status',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_off_grid_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'problem',
+ 'friendly_name': 'Mock Title Off grid status',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.mock_title_off_grid_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_output_fault_status-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': ,
+ 'entity_id': 'binary_sensor.mock_title_output_fault_status',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Output fault status',
+ 'platform': 'apsystems',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'output_fault_status',
+ 'unique_id': 'MY_SERIAL_NUMBER_output_fault_status',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_entities[binary_sensor.mock_title_output_fault_status-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'problem',
+ 'friendly_name': 'Mock Title Output fault status',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.mock_title_output_fault_status',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/apsystems/test_binary_sensor.py b/tests/components/apsystems/test_binary_sensor.py
new file mode 100644
index 00000000000000..0c6fbffc93c458
--- /dev/null
+++ b/tests/components/apsystems/test_binary_sensor.py
@@ -0,0 +1,31 @@
+"""Test the APSystem binary sensor module."""
+
+from unittest.mock import AsyncMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+async def test_all_entities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_apsystems: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test all entities."""
+ with patch(
+ "homeassistant.components.apsystems.PLATFORMS",
+ [Platform.BINARY_SENSOR],
+ ):
+ await setup_integration(hass, mock_config_entry)
+ await snapshot_platform(
+ hass, entity_registry, snapshot, mock_config_entry.entry_id
+ )
diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py
index c041a54d8fa578..b7bf83a7ed0a0c 100644
--- a/tests/components/assist_pipeline/conftest.py
+++ b/tests/components/assist_pipeline/conftest.py
@@ -11,6 +11,12 @@
from homeassistant.components import stt, tts, wake_word
from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select
+from homeassistant.components.assist_pipeline.const import (
+ BYTES_PER_CHUNK,
+ SAMPLE_CHANNELS,
+ SAMPLE_RATE,
+ SAMPLE_WIDTH,
+)
from homeassistant.components.assist_pipeline.pipeline import (
PipelineData,
PipelineStorageCollection,
@@ -33,11 +39,12 @@
_TRANSCRIPT = "test transcript"
+BYTES_ONE_SECOND = SAMPLE_RATE * SAMPLE_WIDTH * SAMPLE_CHANNELS
+
@pytest.fixture(autouse=True)
-def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path:
+def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None:
"""Mock the TTS cache dir with empty dir."""
- return mock_tts_cache_dir
class BaseProvider:
@@ -146,7 +153,7 @@ class MockTTSPlatform(MockPlatform):
PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA
- def __init__(self, *, async_get_engine, **kwargs):
+ def __init__(self, *, async_get_engine, **kwargs: Any) -> None:
"""Initialize the tts platform."""
super().__init__(**kwargs)
self.async_get_engine = async_get_engine
@@ -173,7 +180,7 @@ def mock_stt_provider_entity() -> MockSttProviderEntity:
class MockSttPlatform(MockPlatform):
"""Provide a fake STT platform."""
- def __init__(self, *, async_get_engine, **kwargs):
+ def __init__(self, *, async_get_engine, **kwargs: Any) -> None:
"""Initialize the stt platform."""
super().__init__(**kwargs)
self.async_get_engine = async_get_engine
@@ -462,3 +469,8 @@ def pipeline_data(hass: HomeAssistant, init_components) -> PipelineData:
def pipeline_storage(pipeline_data) -> PipelineStorageCollection:
"""Return pipeline storage collection."""
return pipeline_data.pipeline_store
+
+
+def make_10ms_chunk(header: bytes) -> bytes:
+ """Return 10ms of zeros with the given header."""
+ return header + bytes(BYTES_PER_CHUNK - len(header))
diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
index 0b04b67bb22846..fb1ca6db121fa5 100644
--- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
@@ -440,7 +440,7 @@
# ---
# name: test_device_capture_override.2
dict({
- 'audio': 'Y2h1bmsx',
+ 'audio': 'Y2h1bmsxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
'channels': 1,
'rate': 16000,
'type': 'audio',
@@ -663,9 +663,6 @@
# name: test_stt_stream_failed.2
None
# ---
-# name: test_text_only_pipeline.3
- None
-# ---
# name: test_text_only_pipeline[extra_msg0]
dict({
'language': 'en',
diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py
index f9b91af3bf1aab..4206a288331f57 100644
--- a/tests/components/assist_pipeline/test_init.py
+++ b/tests/components/assist_pipeline/test_init.py
@@ -13,6 +13,7 @@
from homeassistant.components import assist_pipeline, media_source, stt, tts
from homeassistant.components.assist_pipeline.const import (
+ BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR,
DOMAIN,
)
@@ -20,16 +21,16 @@
from homeassistant.setup import async_setup_component
from .conftest import (
+ BYTES_ONE_SECOND,
MockSttProvider,
MockSttProviderEntity,
MockTTSProvider,
MockWakeWordEntity,
+ make_10ms_chunk,
)
from tests.typing import ClientSessionGenerator, WebSocketGenerator
-BYTES_ONE_SECOND = 16000 * 2
-
def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]:
"""Process events to remove dynamic values."""
@@ -58,8 +59,8 @@ async def test_pipeline_from_audio_stream_auto(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
await assist_pipeline.async_pipeline_from_audio_stream(
@@ -75,13 +76,13 @@ async def audio_data():
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_data(),
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert process_events(events) == snapshot
- assert mock_stt_provider.received == [b"part1", b"part2"]
+ assert len(mock_stt_provider.received) == 2
+ assert mock_stt_provider.received[0].startswith(b"part1")
+ assert mock_stt_provider.received[1].startswith(b"part2")
async def test_pipeline_from_audio_stream_legacy(
@@ -100,8 +101,8 @@ async def test_pipeline_from_audio_stream_legacy(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
# Create a pipeline using an stt entity
@@ -140,13 +141,13 @@ async def audio_data():
),
stt_stream=audio_data(),
pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert process_events(events) == snapshot
- assert mock_stt_provider.received == [b"part1", b"part2"]
+ assert len(mock_stt_provider.received) == 2
+ assert mock_stt_provider.received[0].startswith(b"part1")
+ assert mock_stt_provider.received[1].startswith(b"part2")
async def test_pipeline_from_audio_stream_entity(
@@ -165,8 +166,8 @@ async def test_pipeline_from_audio_stream_entity(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
# Create a pipeline using an stt entity
@@ -205,13 +206,13 @@ async def audio_data():
),
stt_stream=audio_data(),
pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert process_events(events) == snapshot
- assert mock_stt_provider_entity.received == [b"part1", b"part2"]
+ assert len(mock_stt_provider_entity.received) == 2
+ assert mock_stt_provider_entity.received[0].startswith(b"part1")
+ assert mock_stt_provider_entity.received[1].startswith(b"part2")
async def test_pipeline_from_audio_stream_no_stt(
@@ -230,8 +231,8 @@ async def test_pipeline_from_audio_stream_no_stt(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
# Create a pipeline without stt support
@@ -271,9 +272,7 @@ async def audio_data():
),
stt_stream=audio_data(),
pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert not events
@@ -293,8 +292,8 @@ async def test_pipeline_from_audio_stream_unknown_pipeline(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
# Try to use the created pipeline
@@ -335,24 +334,25 @@ async def test_pipeline_from_audio_stream_wake_word(
# [0, 2, ...]
wake_chunk_2 = bytes(it.islice(it.cycle(range(0, 256, 2)), BYTES_ONE_SECOND))
- bytes_per_chunk = int(0.01 * BYTES_ONE_SECOND)
+ samples_per_chunk = 160 # 10ms @ 16Khz
+ bytes_per_chunk = samples_per_chunk * 2 # 16-bit
async def audio_data():
- # 1 second in 10 ms chunks
+ # 1 second in chunks
i = 0
while i < len(wake_chunk_1):
yield wake_chunk_1[i : i + bytes_per_chunk]
i += bytes_per_chunk
- # 1 second in 30 ms chunks
+ # 1 second in chunks
i = 0
while i < len(wake_chunk_2):
yield wake_chunk_2[i : i + bytes_per_chunk]
i += bytes_per_chunk
- yield b"wake word!"
- yield b"part1"
- yield b"part2"
+ for header in (b"wake word!", b"part1", b"part2"):
+ yield make_10ms_chunk(header)
+
yield b""
await assist_pipeline.async_pipeline_from_audio_stream(
@@ -372,9 +372,7 @@ async def audio_data():
wake_word_settings=assist_pipeline.WakeWordSettings(
audio_seconds_to_buffer=1.5
),
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
assert process_events(events) == snapshot
@@ -390,7 +388,9 @@ async def audio_data():
)
assert first_chunk == wake_chunk_1[len(wake_chunk_1) // 2 :] + wake_chunk_2
- assert mock_stt_provider.received[-3:] == [b"queued audio", b"part1", b"part2"]
+ assert mock_stt_provider.received[-3] == b"queued audio"
+ assert mock_stt_provider.received[-2].startswith(b"part1")
+ assert mock_stt_provider.received[-1].startswith(b"part2")
async def test_pipeline_save_audio(
@@ -413,13 +413,11 @@ async def test_pipeline_save_audio(
pipeline = assist_pipeline.async_get_pipeline(hass)
events: list[assist_pipeline.PipelineEvent] = []
- # Pad out to an even number of bytes since these "samples" will be saved
- # as 16-bit values.
async def audio_data():
- yield b"wake word_"
+ yield make_10ms_chunk(b"wake word")
# queued audio
- yield b"part1_"
- yield b"part2_"
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
await assist_pipeline.async_pipeline_from_audio_stream(
@@ -438,9 +436,7 @@ async def audio_data():
pipeline_id=pipeline.id,
start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
end_stage=assist_pipeline.PipelineStage.STT,
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
)
pipeline_dirs = list(temp_dir.iterdir())
@@ -464,12 +460,16 @@ async def audio_data():
# Verify wake file
with wave.open(str(wake_file), "rb") as wake_wav:
wake_data = wake_wav.readframes(wake_wav.getnframes())
- assert wake_data == b"wake word_"
+ assert wake_data.startswith(b"wake word")
# Verify stt file
with wave.open(str(stt_file), "rb") as stt_wav:
stt_data = stt_wav.readframes(stt_wav.getnframes())
- assert stt_data == b"queued audiopart1_part2_"
+ assert stt_data.startswith(b"queued audio")
+ stt_data = stt_data[len(b"queued audio") :]
+ assert stt_data.startswith(b"part1")
+ stt_data = stt_data[BYTES_PER_CHUNK:]
+ assert stt_data.startswith(b"part2")
async def test_pipeline_saved_audio_with_device_id(
@@ -652,10 +652,10 @@ async def test_wake_word_detection_aborted(
events: list[assist_pipeline.PipelineEvent] = []
async def audio_data():
- yield b"silence!"
- yield b"wake word!"
- yield b"part1"
- yield b"part2"
+ yield make_10ms_chunk(b"silence!")
+ yield make_10ms_chunk(b"wake word!")
+ yield make_10ms_chunk(b"part1")
+ yield make_10ms_chunk(b"part2")
yield b""
pipeline_store = pipeline_data.pipeline_store
@@ -685,9 +685,7 @@ async def audio_data():
wake_word_settings=assist_pipeline.WakeWordSettings(
audio_seconds_to_buffer=1.5
),
- audio_settings=assist_pipeline.AudioSettings(
- is_vad_enabled=False, is_chunking_enabled=False
- ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
),
)
await pipeline_input.validate()
diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py
index 139ae9152634a2..db039ab3140fb4 100644
--- a/tests/components/assist_pipeline/test_vad.py
+++ b/tests/components/assist_pipeline/test_vad.py
@@ -1,11 +1,9 @@
"""Tests for voice command segmenter."""
import itertools as it
-from unittest.mock import patch
from homeassistant.components.assist_pipeline.vad import (
AudioBuffer,
- VoiceActivityDetector,
VoiceCommandSegmenter,
chunk_samples,
)
@@ -19,15 +17,12 @@ def test_silence() -> None:
# True return value indicates voice command has not finished
assert segmenter.process(_ONE_SECOND * 3, False)
+ assert not segmenter.in_command
def test_speech() -> None:
"""Test that silence + speech + silence triggers a voice command."""
- def is_speech(chunk):
- """Anything non-zero is speech."""
- return sum(chunk) > 0
-
segmenter = VoiceCommandSegmenter()
# silence
@@ -35,68 +30,52 @@ def is_speech(chunk):
# "speech"
assert segmenter.process(_ONE_SECOND, True)
+ assert segmenter.in_command
# silence
# False return value indicates voice command is finished
assert not segmenter.process(_ONE_SECOND, False)
+ assert not segmenter.in_command
def test_audio_buffer() -> None:
"""Test audio buffer wrapping."""
- class DisabledVad(VoiceActivityDetector):
- def is_speech(self, chunk):
- return False
+ samples_per_chunk = 160 # 10 ms
+ bytes_per_chunk = samples_per_chunk * 2
+ leftover_buffer = AudioBuffer(bytes_per_chunk)
- @property
- def samples_per_chunk(self):
- return 160 # 10 ms
+ # Partially fill audio buffer
+ half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2))
+ chunks = list(chunk_samples(half_chunk, bytes_per_chunk, leftover_buffer))
- vad = DisabledVad()
- bytes_per_chunk = vad.samples_per_chunk * 2
- vad_buffer = AudioBuffer(bytes_per_chunk)
- segmenter = VoiceCommandSegmenter()
+ assert not chunks
+ assert leftover_buffer.bytes() == half_chunk
+
+ # Fill and wrap with 1/4 chunk left over
+ three_quarters_chunk = bytes(
+ it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk))
+ )
+ chunks = list(chunk_samples(three_quarters_chunk, bytes_per_chunk, leftover_buffer))
+
+ assert len(chunks) == 1
+ assert (
+ leftover_buffer.bytes()
+ == three_quarters_chunk[len(three_quarters_chunk) - (bytes_per_chunk // 4) :]
+ )
+ assert chunks[0] == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2]
+
+ # Run 2 chunks through
+ leftover_buffer.clear()
+ assert len(leftover_buffer) == 0
+
+ two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2))
+ chunks = list(chunk_samples(two_chunks, bytes_per_chunk, leftover_buffer))
- with patch.object(vad, "is_speech", return_value=False) as mock_process:
- # Partially fill audio buffer
- half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2))
- segmenter.process_with_vad(half_chunk, vad, vad_buffer)
-
- assert not mock_process.called
- assert vad_buffer is not None
- assert vad_buffer.bytes() == half_chunk
-
- # Fill and wrap with 1/4 chunk left over
- three_quarters_chunk = bytes(
- it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk))
- )
- segmenter.process_with_vad(three_quarters_chunk, vad, vad_buffer)
-
- assert mock_process.call_count == 1
- assert (
- vad_buffer.bytes()
- == three_quarters_chunk[
- len(three_quarters_chunk) - (bytes_per_chunk // 4) :
- ]
- )
- assert (
- mock_process.call_args[0][0]
- == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2]
- )
-
- # Run 2 chunks through
- segmenter.reset()
- vad_buffer.clear()
- assert len(vad_buffer) == 0
-
- mock_process.reset_mock()
- two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2))
- segmenter.process_with_vad(two_chunks, vad, vad_buffer)
-
- assert mock_process.call_count == 2
- assert len(vad_buffer) == 0
- assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk]
- assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:]
+ assert len(chunks) == 2
+ assert len(leftover_buffer) == 0
+ assert chunks[0] == two_chunks[:bytes_per_chunk]
+ assert chunks[1] == two_chunks[bytes_per_chunk:]
def test_partial_chunk() -> None:
@@ -127,41 +106,103 @@ def test_chunk_samples_leftover() -> None:
assert leftover_chunk_buffer.bytes() == bytes([5, 6])
-def test_vad_no_chunking() -> None:
- """Test VAD that doesn't require chunking."""
+def test_silence_seconds() -> None:
+ """Test end of voice command silence seconds."""
+
+ segmenter = VoiceCommandSegmenter(silence_seconds=1.0)
+
+ # silence
+ assert segmenter.process(_ONE_SECOND, False)
+ assert not segmenter.in_command
+
+ # "speech"
+ assert segmenter.process(_ONE_SECOND, True)
+ assert segmenter.in_command
+
+ # not enough silence to end
+ assert segmenter.process(_ONE_SECOND * 0.5, False)
+ assert segmenter.in_command
+
+ # exactly enough silence now
+ assert not segmenter.process(_ONE_SECOND * 0.5, False)
+ assert not segmenter.in_command
+
+
+def test_silence_reset() -> None:
+ """Test that speech resets end of voice command detection."""
+
+ segmenter = VoiceCommandSegmenter(silence_seconds=1.0, reset_seconds=0.5)
+
+ # silence
+ assert segmenter.process(_ONE_SECOND, False)
+ assert not segmenter.in_command
+
+ # "speech"
+ assert segmenter.process(_ONE_SECOND, True)
+ assert segmenter.in_command
+
+ # not enough silence to end
+ assert segmenter.process(_ONE_SECOND * 0.5, False)
+ assert segmenter.in_command
+
+ # speech should reset silence detection
+ assert segmenter.process(_ONE_SECOND * 0.5, True)
+ assert segmenter.in_command
- class VadNoChunk(VoiceActivityDetector):
- def is_speech(self, chunk: bytes) -> bool:
- return sum(chunk) > 0
+ # not enough silence to end
+ assert segmenter.process(_ONE_SECOND * 0.5, False)
+ assert segmenter.in_command
- @property
- def samples_per_chunk(self) -> int | None:
- return None
+ # exactly enough silence now
+ assert not segmenter.process(_ONE_SECOND * 0.5, False)
+ assert not segmenter.in_command
+
+
+def test_speech_reset() -> None:
+ """Test that silence resets start of voice command detection."""
- vad = VadNoChunk()
segmenter = VoiceCommandSegmenter(
- speech_seconds=1.0, silence_seconds=1.0, reset_seconds=0.5
+ silence_seconds=1.0, reset_seconds=0.5, speech_seconds=1.0
)
- silence = bytes([0] * 16000)
- speech = bytes([255] * (16000 // 2))
-
- # Test with differently-sized chunks
- assert vad.is_speech(speech)
- assert not vad.is_speech(silence)
-
- # Simulate voice command
- assert segmenter.process_with_vad(silence, vad, None)
- # begin
- assert segmenter.process_with_vad(speech, vad, None)
- assert segmenter.process_with_vad(speech, vad, None)
- assert segmenter.process_with_vad(speech, vad, None)
- # reset with silence
- assert segmenter.process_with_vad(silence, vad, None)
- # resume
- assert segmenter.process_with_vad(speech, vad, None)
- assert segmenter.process_with_vad(speech, vad, None)
- assert segmenter.process_with_vad(speech, vad, None)
- assert segmenter.process_with_vad(speech, vad, None)
- # end
- assert segmenter.process_with_vad(silence, vad, None)
- assert not segmenter.process_with_vad(silence, vad, None)
+
+ # silence
+ assert segmenter.process(_ONE_SECOND, False)
+ assert not segmenter.in_command
+
+ # not enough speech to start voice command
+ assert segmenter.process(_ONE_SECOND * 0.5, True)
+ assert not segmenter.in_command
+
+ # silence should reset speech detection
+ assert segmenter.process(_ONE_SECOND, False)
+ assert not segmenter.in_command
+
+ # not enough speech to start voice command
+ assert segmenter.process(_ONE_SECOND * 0.5, True)
+ assert not segmenter.in_command
+
+ # exactly enough speech now
+ assert segmenter.process(_ONE_SECOND * 0.5, True)
+ assert segmenter.in_command
+
+
+def test_timeout() -> None:
+ """Test that voice command detection times out."""
+
+ segmenter = VoiceCommandSegmenter(timeout_seconds=1.0)
+
+ # not enough to time out
+ assert not segmenter.timed_out
+ assert segmenter.process(_ONE_SECOND * 0.5, False)
+ assert not segmenter.timed_out
+
+ # enough to time out
+ assert not segmenter.process(_ONE_SECOND * 0.5, True)
+ assert segmenter.timed_out
+
+ # flag resets with more audio
+ assert segmenter.process(_ONE_SECOND * 0.5, True)
+ assert not segmenter.timed_out
+
+ assert not segmenter.process(_ONE_SECOND * 0.5, False)
+ assert segmenter.timed_out
diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py
index de8ddc7ccc7ccf..2da914f4252b0b 100644
--- a/tests/components/assist_pipeline/test_websocket.py
+++ b/tests/components/assist_pipeline/test_websocket.py
@@ -8,7 +8,12 @@
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.assist_pipeline.const import DOMAIN
+from homeassistant.components.assist_pipeline.const import (
+ DOMAIN,
+ SAMPLE_CHANNELS,
+ SAMPLE_RATE,
+ SAMPLE_WIDTH,
+)
from homeassistant.components.assist_pipeline.pipeline import (
DeviceAudioQueue,
Pipeline,
@@ -18,7 +23,13 @@
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
-from .conftest import MockWakeWordEntity, MockWakeWordEntity2
+from .conftest import (
+ BYTES_ONE_SECOND,
+ BYTES_PER_CHUNK,
+ MockWakeWordEntity,
+ MockWakeWordEntity2,
+ make_10ms_chunk,
+)
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@@ -205,7 +216,7 @@ async def test_audio_pipeline_with_wake_word_timeout(
"start_stage": "wake_word",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
"timeout": 1,
},
}
@@ -229,7 +240,7 @@ async def test_audio_pipeline_with_wake_word_timeout(
events.append(msg["event"])
# 2 seconds of silence
- await client.send_bytes(bytes([1]) + bytes(16000 * 2 * 2))
+ await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND))
# Time out error
msg = await client.receive_json()
@@ -259,12 +270,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout(
"type": "assist_pipeline/run",
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "timeout": 0,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True},
}
)
@@ -287,9 +293,10 @@ async def test_audio_pipeline_with_wake_word_no_timeout(
events.append(msg["event"])
# "audio"
- await client.send_bytes(bytes([handler_id]) + b"wake word")
+ await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word"))
- msg = await client.receive_json()
+ async with asyncio.timeout(1):
+ msg = await client.receive_json()
assert msg["event"]["type"] == "wake_word-end"
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
@@ -370,7 +377,7 @@ async def test_audio_pipeline_no_wake_word_engine(
"start_stage": "wake_word",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
},
}
)
@@ -407,7 +414,7 @@ async def test_audio_pipeline_no_wake_word_entity(
"start_stage": "wake_word",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
},
}
)
@@ -1776,7 +1783,7 @@ async def test_audio_pipeline_with_enhancements(
"start_stage": "stt",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
# Enhancements
"noise_suppression_level": 2,
"auto_gain_dbfs": 15,
@@ -1806,7 +1813,7 @@ async def test_audio_pipeline_with_enhancements(
# One second of silence.
# This will pass through the audio enhancement pipeline, but we don't test
# the actual output.
- await client.send_bytes(bytes([handler_id]) + bytes(16000 * 2))
+ await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND))
# End of audio stream (handler id + empty payload)
await client.send_bytes(bytes([handler_id]))
@@ -1876,11 +1883,7 @@ async def test_wake_word_cooldown_same_id(
"type": "assist_pipeline/run",
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -1889,11 +1892,7 @@ async def test_wake_word_cooldown_same_id(
"type": "assist_pipeline/run",
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -1927,8 +1926,8 @@ async def test_wake_word_cooldown_same_id(
assert msg["event"]["data"] == snapshot
# Wake both up at the same time
- await client_1.send_bytes(bytes([handler_id_1]) + b"wake word")
- await client_2.send_bytes(bytes([handler_id_2]) + b"wake word")
+ await client_1.send_bytes(bytes([handler_id_1]) + make_10ms_chunk(b"wake word"))
+ await client_2.send_bytes(bytes([handler_id_2]) + make_10ms_chunk(b"wake word"))
# Get response events
error_data: dict[str, Any] | None = None
@@ -1967,11 +1966,7 @@ async def test_wake_word_cooldown_different_ids(
"type": "assist_pipeline/run",
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -1980,11 +1975,7 @@ async def test_wake_word_cooldown_different_ids(
"type": "assist_pipeline/run",
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -2018,8 +2009,8 @@ async def test_wake_word_cooldown_different_ids(
assert msg["event"]["data"] == snapshot
# Wake both up at the same time, but they will have different wake word ids
- await client_1.send_bytes(bytes([handler_id_1]) + b"wake word")
- await client_2.send_bytes(bytes([handler_id_2]) + b"wake word")
+ await client_1.send_bytes(bytes([handler_id_1]) + make_10ms_chunk(b"wake word"))
+ await client_2.send_bytes(bytes([handler_id_2]) + make_10ms_chunk(b"wake word"))
# Get response events
msg = await client_1.receive_json()
@@ -2094,11 +2085,7 @@ async def test_wake_word_cooldown_different_entities(
"pipeline": pipeline_id_1,
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -2109,11 +2096,7 @@ async def test_wake_word_cooldown_different_entities(
"pipeline": pipeline_id_2,
"start_stage": "wake_word",
"end_stage": "tts",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
}
)
@@ -2148,8 +2131,8 @@ async def test_wake_word_cooldown_different_entities(
# Wake both up at the same time.
# They will have the same wake word id, but different entities.
- await client_1.send_bytes(bytes([handler_id_1]) + b"wake word")
- await client_2.send_bytes(bytes([handler_id_2]) + b"wake word")
+ await client_1.send_bytes(bytes([handler_id_1]) + make_10ms_chunk(b"wake word"))
+ await client_2.send_bytes(bytes([handler_id_2]) + make_10ms_chunk(b"wake word"))
# Get response events
error_data: dict[str, Any] | None = None
@@ -2187,7 +2170,11 @@ async def test_device_capture(
identifiers={("demo", "satellite-1234")},
)
- audio_chunks = [b"chunk1", b"chunk2", b"chunk3"]
+ audio_chunks = [
+ make_10ms_chunk(b"chunk1"),
+ make_10ms_chunk(b"chunk2"),
+ make_10ms_chunk(b"chunk3"),
+ ]
# Start capture
client_capture = await hass_ws_client(hass)
@@ -2210,11 +2197,7 @@ async def test_device_capture(
"type": "assist_pipeline/run",
"start_stage": "stt",
"end_stage": "stt",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
"device_id": satellite_device.id,
}
)
@@ -2265,9 +2248,9 @@ async def test_device_capture(
# Verify audio chunks
for i, audio_chunk in enumerate(audio_chunks):
assert events[i]["type"] == "audio"
- assert events[i]["rate"] == 16000
- assert events[i]["width"] == 2
- assert events[i]["channels"] == 1
+ assert events[i]["rate"] == SAMPLE_RATE
+ assert events[i]["width"] == SAMPLE_WIDTH
+ assert events[i]["channels"] == SAMPLE_CHANNELS
# Audio is base64 encoded
assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii")
@@ -2292,7 +2275,11 @@ async def test_device_capture_override(
identifiers={("demo", "satellite-1234")},
)
- audio_chunks = [b"chunk1", b"chunk2", b"chunk3"]
+ audio_chunks = [
+ make_10ms_chunk(b"chunk1"),
+ make_10ms_chunk(b"chunk2"),
+ make_10ms_chunk(b"chunk3"),
+ ]
# Start first capture
client_capture_1 = await hass_ws_client(hass)
@@ -2315,11 +2302,7 @@ async def test_device_capture_override(
"type": "assist_pipeline/run",
"start_stage": "stt",
"end_stage": "stt",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
"device_id": satellite_device.id,
}
)
@@ -2402,9 +2385,9 @@ async def test_device_capture_override(
# Verify all but first audio chunk
for i, audio_chunk in enumerate(audio_chunks[1:]):
assert events[i]["type"] == "audio"
- assert events[i]["rate"] == 16000
- assert events[i]["width"] == 2
- assert events[i]["channels"] == 1
+ assert events[i]["rate"] == SAMPLE_RATE
+ assert events[i]["width"] == SAMPLE_WIDTH
+ assert events[i]["channels"] == SAMPLE_CHANNELS
# Audio is base64 encoded
assert events[i]["audio"] == base64.b64encode(audio_chunk).decode("ascii")
@@ -2464,11 +2447,7 @@ def put_nowait(self, item):
"type": "assist_pipeline/run",
"start_stage": "stt",
"end_stage": "stt",
- "input": {
- "sample_rate": 16000,
- "no_vad": True,
- "no_chunking": True,
- },
+ "input": {"sample_rate": SAMPLE_RATE, "no_vad": True},
"device_id": satellite_device.id,
}
)
@@ -2489,8 +2468,8 @@ def put_nowait(self, item):
assert msg["event"]["type"] == "stt-start"
assert msg["event"]["data"] == snapshot
- # Single sample will "overflow" the queue
- await client_pipeline.send_bytes(bytes([handler_id, 0, 0]))
+ # Single chunk will "overflow" the queue
+ await client_pipeline.send_bytes(bytes([handler_id]) + bytes(BYTES_PER_CHUNK))
# End of audio stream
await client_pipeline.send_bytes(bytes([handler_id]))
@@ -2598,7 +2577,7 @@ async def test_stt_cooldown_same_id(
"start_stage": "stt",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
"wake_word_phrase": "ok_nabu",
},
}
@@ -2610,7 +2589,7 @@ async def test_stt_cooldown_same_id(
"start_stage": "stt",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
"wake_word_phrase": "ok_nabu",
},
}
@@ -2669,7 +2648,7 @@ async def test_stt_cooldown_different_ids(
"start_stage": "stt",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
"wake_word_phrase": "ok_nabu",
},
}
@@ -2681,7 +2660,7 @@ async def test_stt_cooldown_different_ids(
"start_stage": "stt",
"end_stage": "tts",
"input": {
- "sample_rate": 16000,
+ "sample_rate": SAMPLE_RATE,
"wake_word_phrase": "hey_jarvis",
},
}
diff --git a/tests/components/asterisk_mbox/__init__.py b/tests/components/asterisk_mbox/__init__.py
deleted file mode 100644
index 79e3675ad0776a..00000000000000
--- a/tests/components/asterisk_mbox/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the asterisk component."""
diff --git a/tests/components/asterisk_mbox/const.py b/tests/components/asterisk_mbox/const.py
deleted file mode 100644
index 945c6b28d3022e..00000000000000
--- a/tests/components/asterisk_mbox/const.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Asterisk tests constants."""
-
-from homeassistant.components.asterisk_mbox import DOMAIN
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
-
-CONFIG = {
- DOMAIN: {
- CONF_HOST: "localhost",
- CONF_PASSWORD: "password",
- CONF_PORT: 1234,
- }
-}
diff --git a/tests/components/asterisk_mbox/test_init.py b/tests/components/asterisk_mbox/test_init.py
deleted file mode 100644
index d7567ea3286365..00000000000000
--- a/tests/components/asterisk_mbox/test_init.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""Test mailbox."""
-
-from collections.abc import Generator
-from unittest.mock import Mock, patch
-
-import pytest
-
-from homeassistant.components.asterisk_mbox import DOMAIN
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-from homeassistant.setup import async_setup_component
-
-from .const import CONFIG
-
-
-@pytest.fixture
-def client() -> Generator[Mock]:
- """Mock client."""
- with patch(
- "homeassistant.components.asterisk_mbox.asteriskClient", autospec=True
- ) as client:
- yield client
-
-
-async def test_repair_issue_is_created(
- hass: HomeAssistant,
- issue_registry: ir.IssueRegistry,
- client: Mock,
-) -> None:
- """Test repair issue is created."""
- assert await async_setup_component(hass, DOMAIN, CONFIG)
- await hass.async_block_till_done()
- assert (
- DOMAIN,
- "deprecated_integration",
- ) in issue_registry.issues
diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py
index 30be50e75c9d7b..a0f5b55a607193 100644
--- a/tests/components/august/mocks.py
+++ b/tests/components/august/mocks.py
@@ -25,7 +25,7 @@
DoorOperationActivity,
LockOperationActivity,
)
-from yalexs.authenticator import AuthenticationState
+from yalexs.authenticator_common import AuthenticationState
from yalexs.const import Brand
from yalexs.doorbell import Doorbell, DoorbellDetail
from yalexs.lock import Lock, LockDetail
diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py
index aec08864c65060..fdebb8d5c4636d 100644
--- a/tests/components/august/test_config_flow.py
+++ b/tests/components/august/test_config_flow.py
@@ -2,7 +2,7 @@
from unittest.mock import patch
-from yalexs.authenticator import ValidationResult
+from yalexs.authenticator_common import ValidationResult
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from homeassistant import config_entries
diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py
index e605fd74f0a310..74266397ed53e1 100644
--- a/tests/components/august/test_gateway.py
+++ b/tests/components/august/test_gateway.py
@@ -50,5 +50,5 @@ async def _patched_refresh_access_token(
)
await august_gateway.async_refresh_access_token_if_needed()
refresh_access_token_mock.assert_called()
- assert august_gateway.access_token == new_token
+ assert await august_gateway.async_get_access_token() == new_token
assert august_gateway.authentication.access_token_expires == new_token_expire_time
diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr
index 2ff0236a59f562..0aa093d6a6d28c 100644
--- a/tests/components/autarco/snapshots/test_sensor.ambr
+++ b/tests/components/autarco/snapshots/test_sensor.ambr
@@ -401,405 +401,3 @@
'state': '200',
})
# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_1_energy_ac_output_total-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.inverter_test_serial_1_energy_ac_output_total',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Energy AC output total',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'out_ac_energy_total',
- 'unique_id': 'test-serial-1_out_ac_energy_total',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_1_energy_ac_output_total-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'energy',
- 'friendly_name': 'Inverter test-serial-1 Energy AC output total',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.inverter_test_serial_1_energy_ac_output_total',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '10379',
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_1_power_ac_output-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.inverter_test_serial_1_power_ac_output',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Power AC output',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'out_ac_power',
- 'unique_id': 'test-serial-1_out_ac_power',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_1_power_ac_output-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'power',
- 'friendly_name': 'Inverter test-serial-1 Power AC output',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.inverter_test_serial_1_power_ac_output',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '200',
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_2_energy_ac_output_total-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.inverter_test_serial_2_energy_ac_output_total',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Energy AC output total',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'out_ac_energy_total',
- 'unique_id': 'test-serial-2_out_ac_energy_total',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_2_energy_ac_output_total-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'energy',
- 'friendly_name': 'Inverter test-serial-2 Energy AC output total',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.inverter_test_serial_2_energy_ac_output_total',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '10379',
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_2_power_ac_output-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.inverter_test_serial_2_power_ac_output',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Power AC output',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'out_ac_power',
- 'unique_id': 'test-serial-2_out_ac_power',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.inverter_test_serial_2_power_ac_output-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'power',
- 'friendly_name': 'Inverter test-serial-2 Power AC output',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.inverter_test_serial_2_power_ac_output',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '500',
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_month-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': None,
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.solar_energy_production_month',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Energy production month',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'energy_production_month',
- 'unique_id': '1_solar_energy_production_month',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_month-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'energy',
- 'friendly_name': 'Solar Energy production month',
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.solar_energy_production_month',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '58',
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_today-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': None,
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.solar_energy_production_today',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Energy production today',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'energy_production_today',
- 'unique_id': '1_solar_energy_production_today',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_today-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'energy',
- 'friendly_name': 'Solar Energy production today',
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.solar_energy_production_today',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '4',
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_total-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.solar_energy_production_total',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Energy production total',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'energy_production_total',
- 'unique_id': '1_solar_energy_production_total',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.solar_energy_production_total-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'energy',
- 'friendly_name': 'Solar Energy production total',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.solar_energy_production_total',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '10379',
- })
-# ---
-# name: test_solar_sensors[sensor.solar_power_production-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'state_class': ,
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'sensor',
- 'entity_category': None,
- 'entity_id': 'sensor.solar_power_production',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Power production',
- 'platform': 'autarco',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': 'power_production',
- 'unique_id': '1_solar_power_production',
- 'unit_of_measurement': ,
- })
-# ---
-# name: test_solar_sensors[sensor.solar_power_production-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'power',
- 'friendly_name': 'Solar Power production',
- 'state_class': ,
- 'unit_of_measurement': ,
- }),
- 'context': ,
- 'entity_id': 'sensor.solar_power_production',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': '200',
- })
-# ---
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index d8078984630eaf..d8f04f10458b6e 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -88,7 +88,7 @@ async def test_service_data_not_a_dict(
{
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "data": 100},
+ "action": {"action": "test.automation", "data": 100},
}
},
)
@@ -111,7 +111,7 @@ async def test_service_data_single_template(
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data": "{{ { 'foo': 'bar' } }}",
},
}
@@ -136,7 +136,7 @@ async def test_service_specify_data(
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {
"some": (
"{{ trigger.platform }} - {{ trigger.event.event_type }}"
@@ -170,7 +170,7 @@ async def test_service_specify_entity_id(
{
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -192,7 +192,7 @@ async def test_service_specify_entity_id_list(
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"entity_id": ["hello.world", "hello.world2"],
},
}
@@ -216,7 +216,7 @@ async def test_two_triggers(hass: HomeAssistant, calls: list[ServiceCall]) -> No
{"platform": "event", "event_type": "test_event"},
{"platform": "state", "entity_id": "test.entity"},
],
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
}
},
)
@@ -245,7 +245,7 @@ async def test_trigger_service_ignoring_condition(
"entity_id": "non.existing",
"above": "1",
},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
}
},
)
@@ -301,7 +301,7 @@ async def test_two_conditions_with_and(
"below": 150,
},
],
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
}
},
)
@@ -333,7 +333,7 @@ async def test_shorthand_conditions_template(
automation.DOMAIN: {
"trigger": [{"platform": "event", "event_type": "test_event"}],
"condition": "{{ is_state('test.entity', 'hello') }}",
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
}
},
)
@@ -360,11 +360,11 @@ async def test_automation_list_setting(
automation.DOMAIN: [
{
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
{
"trigger": {"platform": "event", "event_type": "test_event_2"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
]
},
@@ -390,8 +390,8 @@ async def test_automation_calling_two_actions(
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
"action": [
- {"service": "test.automation", "data": {"position": 0}},
- {"service": "test.automation", "data": {"position": 1}},
+ {"action": "test.automation", "data": {"position": 0}},
+ {"action": "test.automation", "data": {"position": 1}},
],
}
},
@@ -420,7 +420,7 @@ async def test_shared_context(hass: HomeAssistant, calls: list[ServiceCall]) ->
{
"alias": "bye",
"trigger": {"platform": "event", "event_type": "test_event2"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
]
},
@@ -486,7 +486,7 @@ async def test_services(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
}
},
)
@@ -569,7 +569,7 @@ async def test_reload_config_service(
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
@@ -597,7 +597,7 @@ async def test_reload_config_service(
"alias": "bye",
"trigger": {"platform": "event", "event_type": "test_event2"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
@@ -650,7 +650,7 @@ async def test_reload_config_when_invalid_config(
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
@@ -690,7 +690,7 @@ async def test_reload_config_handles_load_fails(
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
@@ -735,7 +735,7 @@ async def test_automation_stops(
"action": [
{"event": "running"},
{"wait_template": "{{ is_state('test.entity', 'goodbye') }}"},
- {"service": "test.automation"},
+ {"action": "test.automation"},
],
}
}
@@ -811,7 +811,7 @@ async def test_reload_unchanged_does_not_stop(
"action": [
{"event": "running"},
{"wait_template": "{{ is_state('test.entity', 'goodbye') }}"},
- {"service": "test.automation"},
+ {"action": "test.automation"},
],
}
}
@@ -858,7 +858,7 @@ async def test_reload_single_unchanged_does_not_stop(
"action": [
{"event": "running"},
{"wait_template": "{{ is_state('test.entity', 'goodbye') }}"},
- {"service": "test.automation"},
+ {"action": "test.automation"},
],
}
}
@@ -905,7 +905,7 @@ async def test_reload_single_add_automation(
"id": "sun",
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
}
}
assert await async_setup_component(hass, automation.DOMAIN, config1)
@@ -942,25 +942,25 @@ async def test_reload_single_parallel_calls(
"id": "sun",
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event_sun"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"id": "moon",
"alias": "goodbye",
"trigger": {"platform": "event", "event_type": "test_event_moon"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"id": "mars",
"alias": "goodbye",
"trigger": {"platform": "event", "event_type": "test_event_mars"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"id": "venus",
"alias": "goodbye",
"trigger": {"platform": "event", "event_type": "test_event_venus"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
]
}
@@ -1055,7 +1055,7 @@ async def test_reload_single_remove_automation(
"id": "sun",
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
}
}
config2 = {automation.DOMAIN: {}}
@@ -1093,12 +1093,12 @@ async def test_reload_moved_automation_without_alias(
automation.DOMAIN: [
{
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"alias": "automation_with_alias",
"trigger": {"platform": "event", "event_type": "test_event2"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
]
}
@@ -1149,17 +1149,17 @@ async def test_reload_identical_automations_without_id(
{
"alias": "dolly",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"alias": "dolly",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
{
"alias": "dolly",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
]
}
@@ -1246,12 +1246,12 @@ async def test_reload_identical_automations_without_id(
[
{
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
# An automation using templates
{
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "{{ 'test.automation' }}"}],
+ "action": [{"action": "{{ 'test.automation' }}"}],
},
# An automation using blueprint
{
@@ -1278,13 +1278,13 @@ async def test_reload_identical_automations_without_id(
{
"id": "sun",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "test.automation"}],
+ "action": [{"action": "test.automation"}],
},
# An automation using templates
{
"id": "sun",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": [{"service": "{{ 'test.automation' }}"}],
+ "action": [{"action": "{{ 'test.automation' }}"}],
},
# An automation using blueprint
{
@@ -1424,12 +1424,12 @@ async def test_automation_restore_state(hass: HomeAssistant) -> None:
{
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event_hello"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
{
"alias": "bye",
"trigger": {"platform": "event", "event_type": "test_event_bye"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
]
}
@@ -1474,7 +1474,7 @@ async def test_initial_value_off(hass: HomeAssistant) -> None:
"alias": "hello",
"initial_state": "off",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1499,7 +1499,7 @@ async def test_initial_value_on(hass: HomeAssistant) -> None:
"initial_state": "on",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"entity_id": ["hello.world", "hello.world2"],
},
}
@@ -1528,7 +1528,7 @@ async def test_initial_value_off_but_restore_on(hass: HomeAssistant) -> None:
"alias": "hello",
"initial_state": "off",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1553,7 +1553,7 @@ async def test_initial_value_on_but_restore_off(hass: HomeAssistant) -> None:
"alias": "hello",
"initial_state": "on",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1576,7 +1576,7 @@ async def test_no_initial_value_and_restore_off(hass: HomeAssistant) -> None:
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1600,7 +1600,7 @@ async def test_automation_is_on_if_no_initial_state_or_restore(
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1623,7 +1623,7 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None:
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1714,7 +1714,7 @@ async def test_automation_bad_config_validation(
"alias": "good_automation",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"entity_id": "hello.world",
},
},
@@ -1756,7 +1756,7 @@ async def test_automation_bad_config_validation(
"alias": "bad_automation",
"trigger": {"platform": "event", "event_type": "test_event2"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"event": "{{ trigger.event.event_type }}"},
},
}
@@ -1785,7 +1785,7 @@ async def test_automation_with_error_in_script(
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
@@ -1811,7 +1811,7 @@ async def test_automation_with_error_in_script_2(
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": None, "entity_id": "hello.world"},
+ "action": {"action": None, "entity_id": "hello.world"},
}
},
)
@@ -1842,19 +1842,19 @@ async def test_automation_restore_last_triggered_with_initial_state(
"alias": "hello",
"initial_state": "off",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
{
"alias": "bye",
"initial_state": "off",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
{
"alias": "solong",
"initial_state": "on",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation"},
+ "action": {"action": "test.automation"},
},
]
}
@@ -2013,11 +2013,11 @@ async def test_extraction_functions(
},
"action": [
{
- "service": "test.script",
+ "action": "test.script",
"data": {"entity_id": "light.in_both"},
},
{
- "service": "test.script",
+ "action": "test.script",
"data": {"entity_id": "light.in_first"},
},
{
@@ -2027,15 +2027,15 @@ async def test_extraction_functions(
"type": "turn_on",
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"area_id": "area-in-both"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"floor_id": "floor-in-both"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"label_id": "label-in-both"},
},
],
@@ -2087,7 +2087,7 @@ async def test_extraction_functions(
},
"action": [
{
- "service": "test.script",
+ "action": "test.script",
"data": {"entity_id": "light.in_both"},
},
{
@@ -2140,7 +2140,7 @@ async def test_extraction_functions(
},
"action": [
{
- "service": "test.script",
+ "action": "test.script",
"data": {"entity_id": "light.in_both"},
},
{
@@ -2150,27 +2150,27 @@ async def test_extraction_functions(
},
{"scene": "scene.hello"},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"area_id": "area-in-both"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"area_id": "area-in-last"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"floor_id": "floor-in-both"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"floor_id": "floor-in-last"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"label_id": "label-in-both"},
},
{
- "service": "test.test",
+ "action": "test.test",
"target": {"label_id": "label-in-last"},
},
],
@@ -2289,7 +2289,7 @@ async def test_automation_variables(
},
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {
"value": "{{ test_var }}",
"event_type": "{{ event_type }}",
@@ -2308,7 +2308,7 @@ async def test_automation_variables(
"value_template": "{{ trigger.event.data.pass_condition }}",
},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
},
},
{
@@ -2317,7 +2317,7 @@ async def test_automation_variables(
},
"trigger": {"platform": "event", "event_type": "test_event_3"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
},
},
]
@@ -2373,7 +2373,7 @@ async def test_automation_trigger_variables(
},
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {
"value": "{{ test_var }}",
"event_type": "{{ event_type }}",
@@ -2391,7 +2391,7 @@ async def test_automation_trigger_variables(
},
"trigger": {"platform": "event", "event_type": "test_event_2"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {
"value": "{{ test_var }}",
"event_type": "{{ event_type }}",
@@ -2438,7 +2438,7 @@ async def test_automation_bad_trigger_variables(
},
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
},
},
]
@@ -2465,7 +2465,7 @@ async def test_automation_this_var_always(
{
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {
"this_template": "{{this.entity_id}}",
},
@@ -2542,7 +2542,7 @@ async def test_blueprint_automation(
"Blueprint 'Call service based on event' generated invalid automation",
(
"value should be a string for dictionary value @"
- " data['action'][0]['service']"
+ " data['action'][0]['action']"
),
),
],
@@ -2640,7 +2640,7 @@ async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) ->
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {
- "service": "test.automation",
+ "action": "test.automation",
"data_template": {"trigger": "{{ trigger }}"},
},
}
@@ -2679,14 +2679,14 @@ async def test_trigger_condition_implicit_id(
{
"conditions": {"condition": "trigger", "id": [0, "2"]},
"sequence": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {"param": "one"},
},
},
{
"conditions": {"condition": "trigger", "id": "1"},
"sequence": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {"param": "two"},
},
},
@@ -2730,14 +2730,14 @@ async def test_trigger_condition_explicit_id(
{
"conditions": {"condition": "trigger", "id": "one"},
"sequence": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {"param": "one"},
},
},
{
"conditions": {"condition": "trigger", "id": "two"},
"sequence": {
- "service": "test.automation",
+ "action": "test.automation",
"data": {"param": "two"},
},
},
@@ -2822,8 +2822,8 @@ async def mock_stop_scripts_at_shutdown(*args):
f" {automation_runs} }}}}"
)
},
- {"service": "script.script1"},
- {"service": "test.script_done"},
+ {"action": "script.script1"},
+ {"action": "test.script_done"},
],
},
}
@@ -2840,9 +2840,9 @@ async def mock_stop_scripts_at_shutdown(*args):
{"platform": "event", "event_type": "trigger_automation"},
],
"action": [
- {"service": "test.automation_started"},
+ {"action": "test.automation_started"},
{"delay": 0.001},
- {"service": "script.script1"},
+ {"action": "script.script1"},
],
}
},
@@ -2923,7 +2923,7 @@ async def stop_scripts_at_shutdown(*args):
],
"action": [
{"event": "trigger_automation"},
- {"service": "test.automation_done"},
+ {"action": "test.automation_done"},
],
}
},
@@ -2985,7 +2985,7 @@ async def stop_scripts_at_shutdown(*args):
],
"action": [
{"event": "trigger_automation"},
- {"service": "test.automation_done"},
+ {"action": "test.automation_done"},
],
}
},
@@ -3021,7 +3021,7 @@ async def test_websocket_config(
config = {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "data": 100},
+ "action": {"action": "test.automation", "data": 100},
}
assert await async_setup_component(
hass, automation.DOMAIN, {automation.DOMAIN: config}
@@ -3095,7 +3095,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non
"from": "on",
},
"action": {
- "service": "automation.turn_off",
+ "action": "automation.turn_off",
"target": {
"entity_id": "automation.automation_1",
},
@@ -3118,7 +3118,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non
},
},
"action": {
- "service": "persistent_notification.create",
+ "action": "persistent_notification.create",
"metadata": {},
"data": {
"message": "Test race",
@@ -3185,7 +3185,7 @@ def _save_event(event):
"fire_toggle": {
"sequence": [
{
- "service": "input_boolean.toggle",
+ "action": "input_boolean.toggle",
"target": {"entity_id": "input_boolean.test_1"},
}
]
@@ -3206,7 +3206,7 @@ def _save_event(event):
"to": "on",
},
"action": {
- "service": "script.fire_toggle",
+ "action": "script.fire_toggle",
},
"id": "automation_0",
"mode": "single",
@@ -3218,7 +3218,7 @@ def _save_event(event):
"to": "on",
},
"action": {
- "service": "script.fire_toggle",
+ "action": "script.fire_toggle",
},
"id": "automation_1",
"mode": "single",
@@ -3301,3 +3301,29 @@ async def test_two_automation_call_restart_script_right_after_each_other(
hass.states.async_set("input_boolean.test_2", "on")
await hass.async_block_till_done()
assert len(events) == 1
+
+
+async def test_action_service_backward_compatibility(
+ hass: HomeAssistant, calls: list[ServiceCall]
+) -> None:
+ """Test we can still use the service call method."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "action": {
+ "service": "test.automation",
+ "entity_id": "hello.world",
+ "data": {"event": "{{ trigger.event.event_type }}"},
+ },
+ }
+ },
+ )
+
+ hass.bus.async_fire("test_event")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data.get(ATTR_ENTITY_ID) == ["hello.world"]
+ assert calls[0].data.get("event") == "test_event"
diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py
index af3d0c4115189b..be354abe9d2a79 100644
--- a/tests/components/automation/test_recorder.py
+++ b/tests/components/automation/test_recorder.py
@@ -40,7 +40,7 @@ async def test_exclude_attributes(
{
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
- "action": {"service": "test.automation", "entity_id": "hello.world"},
+ "action": {"action": "test.automation", "entity_id": "hello.world"},
}
},
)
diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py
index 9589ad6c0371f0..820b08e51b4b5c 100644
--- a/tests/components/aws/test_init.py
+++ b/tests/components/aws/test_init.py
@@ -1,6 +1,7 @@
"""Tests for the aws component config and setup."""
import json
+from typing import Any
from unittest.mock import AsyncMock, MagicMock, call, patch as async_patch
from homeassistant.core import HomeAssistant
@@ -10,7 +11,7 @@
class MockAioSession:
"""Mock AioSession."""
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Init a mock session."""
self.get_user = AsyncMock()
self.invoke = AsyncMock()
diff --git a/tests/components/axis/snapshots/test_binary_sensor.ambr b/tests/components/axis/snapshots/test_binary_sensor.ambr
index 94b1cc2fc2efd1..ab860489d55dbf 100644
--- a/tests/components/axis/snapshots/test_binary_sensor.ambr
+++ b/tests/components/axis/snapshots/test_binary_sensor.ambr
@@ -1,33 +1,5 @@
# serializer version: 1
-# name: test_binary_sensors[event0-binary_sensor.name_daynight_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'light',
- 'friendly_name': 'name DayNight 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_daynight_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event0-daynight_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'light',
- 'friendly_name': 'name DayNight 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_daynight_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event0-daynight_1][binary_sensor.home_daynight_1-entry]
+# name: test_binary_sensors[event0][binary_sensor.home_daynight_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -60,7 +32,7 @@
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event0-daynight_1][binary_sensor.home_daynight_1-state]
+# name: test_binary_sensors[event0][binary_sensor.home_daynight_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'light',
@@ -74,7 +46,7 @@
'state': 'on',
})
# ---
-# name: test_binary_sensors[event0][binary_sensor.home_daynight_1-entry]
+# name: test_binary_sensors[event10][binary_sensor.home_object_analytics_device1scenario8-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -86,7 +58,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_daynight_1',
+ 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -96,60 +68,32 @@
'name': None,
'options': dict({
}),
- 'original_device_class': ,
+ 'original_device_class': ,
'original_icon': None,
- 'original_name': 'DayNight 1',
+ 'original_name': 'Object Analytics Device1Scenario8',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:VideoSource/tnsaxis:DayNightVision-1',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario8-Device1Scenario8',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event0][binary_sensor.home_daynight_1-state]
+# name: test_binary_sensors[event10][binary_sensor.home_object_analytics_device1scenario8-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'device_class': 'light',
- 'friendly_name': 'home DayNight 1',
+ 'device_class': 'motion',
+ 'friendly_name': 'home Object Analytics Device1Scenario8',
}),
'context': ,
- 'entity_id': 'binary_sensor.home_daynight_1',
+ 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
'last_changed': ,
'last_reported': ,
'last_updated': ,
'state': 'on',
})
# ---
-# name: test_binary_sensors[event1-binary_sensor.name_sound_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'sound',
- 'friendly_name': 'name Sound 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_sound_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event1-sound_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'sound',
- 'friendly_name': 'name Sound 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_sound_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event1-sound_1][binary_sensor.home_sound_1-entry]
+# name: test_binary_sensors[event1][binary_sensor.home_sound_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -182,7 +126,7 @@
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event1-sound_1][binary_sensor.home_sound_1-state]
+# name: test_binary_sensors[event1][binary_sensor.home_sound_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'sound',
@@ -196,35 +140,7 @@
'state': 'off',
})
# ---
-# name: test_binary_sensors[event10-binary_sensor.name_object_analytics_device1scenario8]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Object Analytics Device1Scenario8',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_object_analytics_device1scenario8',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event10-object_analytics_device1scenario8]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Object Analytics Device1Scenario8',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_object_analytics_device1scenario8',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event10-object_analytics_device1scenario8][binary_sensor.home_object_analytics_device1scenario8-entry]
+# name: test_binary_sensors[event2][binary_sensor.home_pir_sensor-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -236,7 +152,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
+ 'entity_id': 'binary_sensor.home_pir_sensor',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -246,32 +162,32 @@
'name': None,
'options': dict({
}),
- 'original_device_class': ,
+ 'original_device_class': ,
'original_icon': None,
- 'original_name': 'Object Analytics Device1Scenario8',
+ 'original_name': 'PIR sensor',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario8-Device1Scenario8',
+ 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:IO/Port-0',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event10-object_analytics_device1scenario8][binary_sensor.home_object_analytics_device1scenario8-state]
+# name: test_binary_sensors[event2][binary_sensor.home_pir_sensor-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'home Object Analytics Device1Scenario8',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'home PIR sensor',
}),
'context': ,
- 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
+ 'entity_id': 'binary_sensor.home_pir_sensor',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'on',
+ 'state': 'off',
})
# ---
-# name: test_binary_sensors[event10][binary_sensor.home_object_analytics_device1scenario8-entry]
+# name: test_binary_sensors[event3][binary_sensor.home_pir_0-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -283,7 +199,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
+ 'entity_id': 'binary_sensor.home_pir_0',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -295,30 +211,30 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'Object Analytics Device1Scenario8',
+ 'original_name': 'PIR 0',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario8-Device1Scenario8',
+ 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:Sensor/PIR-0',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event10][binary_sensor.home_object_analytics_device1scenario8-state]
+# name: test_binary_sensors[event3][binary_sensor.home_pir_0-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'motion',
- 'friendly_name': 'home Object Analytics Device1Scenario8',
+ 'friendly_name': 'home PIR 0',
}),
'context': ,
- 'entity_id': 'binary_sensor.home_object_analytics_device1scenario8',
+ 'entity_id': 'binary_sensor.home_pir_0',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'on',
+ 'state': 'off',
})
# ---
-# name: test_binary_sensors[event1][binary_sensor.home_sound_1-entry]
+# name: test_binary_sensors[event4][binary_sensor.home_fence_guard_profile_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -330,7 +246,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_sound_1',
+ 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -340,60 +256,32 @@
'name': None,
'options': dict({
}),
- 'original_device_class': ,
+ 'original_device_class': ,
'original_icon': None,
- 'original_name': 'Sound 1',
+ 'original_name': 'Fence Guard Profile 1',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:AudioSource/tnsaxis:TriggerLevel-1',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1-Camera1Profile1',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event1][binary_sensor.home_sound_1-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'sound',
- 'friendly_name': 'home Sound 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.home_sound_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event2-binary_sensor.name_pir_sensor]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'connectivity',
- 'friendly_name': 'name PIR sensor',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_pir_sensor',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event2-pir_sensor]
+# name: test_binary_sensors[event4][binary_sensor.home_fence_guard_profile_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'device_class': 'connectivity',
- 'friendly_name': 'name PIR sensor',
+ 'device_class': 'motion',
+ 'friendly_name': 'home Fence Guard Profile 1',
}),
'context': ,
- 'entity_id': 'binary_sensor.name_pir_sensor',
+ 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'off',
+ 'state': 'on',
})
# ---
-# name: test_binary_sensors[event2-pir_sensor][binary_sensor.home_pir_sensor-entry]
+# name: test_binary_sensors[event5][binary_sensor.home_motion_guard_profile_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -405,7 +293,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_pir_sensor',
+ 'entity_id': 'binary_sensor.home_motion_guard_profile_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -415,32 +303,32 @@
'name': None,
'options': dict({
}),
- 'original_device_class': ,
+ 'original_device_class': ,
'original_icon': None,
- 'original_name': 'PIR sensor',
+ 'original_name': 'Motion Guard Profile 1',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:IO/Port-0',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1Profile1-Camera1Profile1',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event2-pir_sensor][binary_sensor.home_pir_sensor-state]
+# name: test_binary_sensors[event5][binary_sensor.home_motion_guard_profile_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
- 'device_class': 'connectivity',
- 'friendly_name': 'home PIR sensor',
+ 'device_class': 'motion',
+ 'friendly_name': 'home Motion Guard Profile 1',
}),
'context': ,
- 'entity_id': 'binary_sensor.home_pir_sensor',
+ 'entity_id': 'binary_sensor.home_motion_guard_profile_1',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'off',
+ 'state': 'on',
})
# ---
-# name: test_binary_sensors[event2][binary_sensor.home_pir_sensor-entry]
+# name: test_binary_sensors[event6][binary_sensor.home_loitering_guard_profile_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -452,7 +340,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_pir_sensor',
+ 'entity_id': 'binary_sensor.home_loitering_guard_profile_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -462,60 +350,32 @@
'name': None,
'options': dict({
}),
- 'original_device_class': ,
+ 'original_device_class': ,
'original_icon': None,
- 'original_name': 'PIR sensor',
+ 'original_name': 'Loitering Guard Profile 1',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:IO/Port-0',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1-Camera1Profile1',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event2][binary_sensor.home_pir_sensor-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'connectivity',
- 'friendly_name': 'home PIR sensor',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.home_pir_sensor',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event3-binary_sensor.name_pir_0]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name PIR 0',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_pir_0',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event3-pir_0]
+# name: test_binary_sensors[event6][binary_sensor.home_loitering_guard_profile_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'motion',
- 'friendly_name': 'name PIR 0',
+ 'friendly_name': 'home Loitering Guard Profile 1',
}),
'context': ,
- 'entity_id': 'binary_sensor.name_pir_0',
+ 'entity_id': 'binary_sensor.home_loitering_guard_profile_1',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'off',
+ 'state': 'on',
})
# ---
-# name: test_binary_sensors[event3-pir_0][binary_sensor.home_pir_0-entry]
+# name: test_binary_sensors[event7][binary_sensor.home_vmd4_profile_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -527,7 +387,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_pir_0',
+ 'entity_id': 'binary_sensor.home_vmd4_profile_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -539,30 +399,30 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'PIR 0',
+ 'original_name': 'VMD4 Profile 1',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:Sensor/PIR-0',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1-Camera1Profile1',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event3-pir_0][binary_sensor.home_pir_0-state]
+# name: test_binary_sensors[event7][binary_sensor.home_vmd4_profile_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'motion',
- 'friendly_name': 'home PIR 0',
+ 'friendly_name': 'home VMD4 Profile 1',
}),
'context': ,
- 'entity_id': 'binary_sensor.home_pir_0',
+ 'entity_id': 'binary_sensor.home_vmd4_profile_1',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'off',
+ 'state': 'on',
})
# ---
-# name: test_binary_sensors[event3][binary_sensor.home_pir_0-entry]
+# name: test_binary_sensors[event8][binary_sensor.home_object_analytics_scenario_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -574,7 +434,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
- 'entity_id': 'binary_sensor.home_pir_0',
+ 'entity_id': 'binary_sensor.home_object_analytics_scenario_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -586,630 +446,20 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'PIR 0',
+ 'original_name': 'Object Analytics Scenario 1',
'platform': 'axis',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tns1:Device/tnsaxis:Sensor/PIR-0',
+ 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1-Device1Scenario1',
'unit_of_measurement': None,
})
# ---
-# name: test_binary_sensors[event3][binary_sensor.home_pir_0-state]
+# name: test_binary_sensors[event8][binary_sensor.home_object_analytics_scenario_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'motion',
- 'friendly_name': 'home PIR 0',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.home_pir_0',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'off',
- })
-# ---
-# name: test_binary_sensors[event4-binary_sensor.name_fence_guard_profile_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Fence Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_fence_guard_profile_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event4-fence_guard_profile_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Fence Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_fence_guard_profile_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event4-fence_guard_profile_1][binary_sensor.home_fence_guard_profile_1-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': None,
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'binary_sensor',
- 'entity_category': None,
- 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Fence Guard Profile 1',
- 'platform': 'axis',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1-Camera1Profile1',
- 'unit_of_measurement': None,
- })
-# ---
-# name: test_binary_sensors[event4-fence_guard_profile_1][binary_sensor.home_fence_guard_profile_1-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'home Fence Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event4][binary_sensor.home_fence_guard_profile_1-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': None,
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'binary_sensor',
- 'entity_category': None,
- 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': ,
- 'original_icon': None,
- 'original_name': 'Fence Guard Profile 1',
- 'platform': 'axis',
- 'previous_unique_id': None,
- 'supported_features': 0,
- 'translation_key': None,
- 'unique_id': '00:40:8c:12:34:56-tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1-Camera1Profile1',
- 'unit_of_measurement': None,
- })
-# ---
-# name: test_binary_sensors[event4][binary_sensor.home_fence_guard_profile_1-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'home Fence Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.home_fence_guard_profile_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event5-binary_sensor.name_motion_guard_profile_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Motion Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_motion_guard_profile_1',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'on',
- })
-# ---
-# name: test_binary_sensors[event5-motion_guard_profile_1]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'device_class': 'motion',
- 'friendly_name': 'name Motion Guard Profile 1',
- }),
- 'context': ,
- 'entity_id': 'binary_sensor.name_motion_guard_profile_1',
- 'last_changed':